UIPackage
Menu

Countdown

countdown ui
Edit on GitHub

Countdown timer that shows time remaining to a target date. 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

$ npx 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

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 5.3 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 }
    })
    
    function pad2(n: number) {
      return props.pad ? String(n).padStart(2, '0') : String(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 startTimer() {
      stopTimer()
      if (props.paused) return
      timer = setInterval(() => {
        now.value = Date.now()
        emit('tick', remainingMs.value)
        if (remainingMs.value <= 0 && !finished.value) {
          finished.value = true
          stopTimer()
          emit('finish')
        }
      }, 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">
          <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 text-2xl font-semibold">
                {{ pad2(parts.days) }}
              </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 text-2xl font-semibold">
                {{ pad2(parts.hours) }}
              </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 text-2xl font-semibold"
              >
                {{ pad2(parts.minutes) }}
              </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 text-2xl font-semibold"
              >
                {{ pad2(parts.seconds) }}
              </span>
            </slot>
          </slot>
        </div>
      </div>
    </template>
  • 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