Countdown
countdown ui Countdown timer that shows time remaining to a target date. Digits flip on each tick. Supports DD:HH:MM:SS, HH:MM:SS, MM:SS, and SS formats plus custom token formats, named slots for days/hours/minutes/seconds, a label, leading-zero padding, a custom separator, paused state, and on-finish/tick events.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/countdown.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/countdown.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/countdown.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/countdown.json Named registry:
npx shadcn-vue@latest add @uipkge/countdown Installs to: app/components/ui/countdown/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
target Target date/time. Accepts a Date, ISO string, or epoch ms number. | Date | string | number | — | required |
format Display format. Custom tokens: DD days, HH hours, MM minutes, SS seconds. | Format | string | 'DD:HH:MM:SS' | optional |
paused Pause the countdown. | boolean | false | optional |
label Optional label rendered above the countdown. | string | '' | optional |
pad Show leading zeros (e.g. 05 vs 5). | boolean | true | optional |
separator Separator between units. | string | ':' | optional |
class | HTMLAttributes['class'] | — | optional |
Files installed (2)
-
app/components/ui/countdown/Countdown.vue 7.7 kB
<script setup lang="ts"> import { computed, onBeforeUnmount, ref, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' type Format = 'DD:HH:MM:SS' | 'HH:MM:SS' | 'MM:SS' | 'SS' const props = withDefaults( defineProps<{ /** Target date/time. Accepts a Date, ISO string, or epoch ms number. */ target: Date | string | number /** Display format. Custom tokens: DD days, HH hours, MM minutes, SS seconds. */ format?: Format | string /** Pause the countdown. */ paused?: boolean /** Optional label rendered above the countdown. */ label?: string /** Show leading zeros (e.g. 05 vs 5). */ pad?: boolean /** Separator between units. */ separator?: string class?: HTMLAttributes['class'] }>(), { format: 'DD:HH:MM:SS', paused: false, label: '', pad: true, separator: ':', }, ) const emit = defineEmits<{ (e: 'finish'): void (e: 'tick', remaining: number): void }>() const now = ref(Date.now()) let timer: ReturnType<typeof setInterval> | null = null const finished = ref(false) const targetMs = computed(() => { if (props.target instanceof Date) return props.target.getTime() if (typeof props.target === 'number') return props.target return new Date(props.target).getTime() }) const remainingMs = computed(() => Math.max(0, targetMs.value - now.value)) const parts = computed(() => { const total = remainingMs.value const days = Math.floor(total / 86_400_000) const hours = Math.floor((total % 86_400_000) / 3_600_000) const minutes = Math.floor((total % 3_600_000) / 60_000) const seconds = Math.floor((total % 60_000) / 1000) return { days, hours, minutes, seconds } }) /** Values actually painted for each unit under the active format (rolled-up totals for compact formats). */ const displayParts = computed(() => { const { days, hours, minutes, seconds } = parts.value const f = props.format if (f === 'HH:MM:SS') { return { days, hours: days * 24 + hours, minutes, seconds } } if (f === 'MM:SS') { return { days, hours, minutes: days * 24 * 60 + hours * 60 + minutes, seconds } } if (f === 'SS') { return { days, hours, minutes, seconds: Math.floor(remainingMs.value / 1000) } } // DD:HH:MM:SS and custom token formats use modular parts. return { days, hours, minutes, seconds } }) function pad2(n: number) { return props.pad ? String(n).padStart(2, '0') : String(n) } /** Split a unit string into chars so only changed digits remount + flip. */ function unitChars(n: number) { return [...pad2(n)] } const display = computed(() => { const f = props.format const { days, hours, minutes, seconds } = parts.value const sep = props.separator if (f === 'DD:HH:MM:SS') return `${pad2(days)}${sep}${pad2(hours)}${sep}${pad2(minutes)}${sep}${pad2(seconds)}` if (f === 'HH:MM:SS') return `${pad2(days * 24 + hours)}${sep}${pad2(minutes)}${sep}${pad2(seconds)}` if (f === 'MM:SS') return `${pad2(days * 24 * 60 + hours * 60 + minutes)}${sep}${pad2(seconds)}` if (f === 'SS') return pad2(Math.floor(remainingMs.value / 1000)) // Custom token format: replace DD, HH, MM, SS tokens. return f .replace('DD', pad2(days)) .replace('HH', pad2(hours)) .replace('MM', pad2(minutes)) .replace('SS', pad2(seconds)) }) function checkFinished() { if (remainingMs.value <= 0 && !finished.value) { finished.value = true stopTimer() emit('finish') return true } return false } function startTimer() { stopTimer() // Fire finish immediately when the target is already past (don't wait for first tick). if (checkFinished()) return if (props.paused) return timer = setInterval(() => { now.value = Date.now() emit('tick', remainingMs.value) checkFinished() }, 1000) } function stopTimer() { if (timer) { clearInterval(timer) timer = null } } watch( () => props.paused, (paused) => { if (paused) stopTimer() else startTimer() }, ) watch( () => props.target, () => { finished.value = false now.value = Date.now() startTimer() }, ) onBeforeUnmount(stopTimer) // Kick off immediately. now.value = Date.now() startTimer() </script> <template> <div data-uipkge data-slot="countdown" :data-finished="finished" :data-paused="paused" :class="cn('inline-flex flex-col gap-1', props.class)" > <span v-if="label" data-slot="countdown-label" class="text-muted-foreground text-xs font-medium tracking-wide uppercase" > {{ label }} </span> <div data-slot="countdown-display" class="flex items-baseline gap-1 font-mono tabular-nums" role="timer" :aria-live="finished || paused ? 'off' : 'polite'" aria-atomic="true" > <slot name="default" :days="parts.days" :hours="parts.hours" :minutes="parts.minutes" :seconds="parts.seconds" :display="display" :finished="finished" > <slot name="days" :days="parts.days"> <span v-if="format.includes('DD')" data-slot="countdown-days" class="text-foreground inline-flex text-2xl font-semibold" > <span v-for="(ch, i) in unitChars(displayParts.days)" :key="`d-${i}-${ch}`" class="countdown-digit inline-block tabular-nums" >{{ ch }}</span > </span> </slot> <span v-if="format.includes('DD') && format.includes('HH')" class="text-muted-foreground text-2xl">{{ separator }}</span> <slot name="hours" :hours="parts.hours"> <span v-if="format.includes('HH')" data-slot="countdown-hours" class="text-foreground inline-flex text-2xl font-semibold" > <span v-for="(ch, i) in unitChars(displayParts.hours)" :key="`h-${i}-${ch}`" class="countdown-digit inline-block tabular-nums" >{{ ch }}</span > </span> </slot> <span v-if="format.includes('HH') && format.includes('MM')" class="text-muted-foreground text-2xl">{{ separator }}</span> <slot name="minutes" :minutes="parts.minutes"> <span v-if="format.includes('MM')" data-slot="countdown-minutes" class="text-foreground inline-flex text-2xl font-semibold" > <span v-for="(ch, i) in unitChars(displayParts.minutes)" :key="`m-${i}-${ch}`" class="countdown-digit inline-block tabular-nums" >{{ ch }}</span > </span> </slot> <span v-if="format.includes('MM') && format.includes('SS')" class="text-muted-foreground text-2xl">{{ separator }}</span> <slot name="seconds" :seconds="parts.seconds"> <span v-if="format.includes('SS')" data-slot="countdown-seconds" class="text-foreground inline-flex text-2xl font-semibold" > <span v-for="(ch, i) in unitChars(displayParts.seconds)" :key="`s-${i}-${ch}`" class="countdown-digit inline-block tabular-nums" >{{ ch }}</span > </span> </slot> </slot> </div> </div> </template> <style> @keyframes countdown-digit-flip { 0% { opacity: 0; transform: translateY(45%) scale(0.92); } 100% { opacity: 1; transform: translateY(0) scale(1); } } [data-slot='countdown'] .countdown-digit { animation: countdown-digit-flip 280ms cubic-bezier(0.22, 1, 0.36, 1) both; } @media (prefers-reduced-motion: reduce) { [data-slot='countdown'] .countdown-digit { animation: none !important; } } </style> -
app/components/ui/countdown/index.ts 0.1 kB
export { default as Countdown } from './Countdown.vue'
Raw manifest: https://uipkge.dev/r/vue/countdown.json