Countdown

The days, hours, minutes and seconds left until a moment: a sale ending, a cart being held, a session running out.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add countdown
pnpm dlx ermineui add countdown
yarn dlx ermineui add countdown
bunx ermineui add countdown

That copies 3 files into src/components/ui/countdown/. Files you already have are kept.

Use

import { Countdown, timeLeft, timeLeftInWords } from '@/components/ui/countdown'
<script setup lang="ts">
import { Countdown } from '@/components/ui/countdown'
</script>

<template>
  <Countdown />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
Countdown.vuesrc/components/ui/countdown/Countdown.vue
index.tssrc/components/ui/countdown/index.ts
time.tssrc/components/ui/countdown/time.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Countdown.vue 102 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { timeLeft, timeLeftInWords, type TimeLeft } from './time'

const props = withDefaults(
  defineProps<{
    /** The moment being counted down to: a Date, a timestamp, or text that Date understands (ISO 8601 is safest). */
    to: Date | number | string
    /** Accessible name: what is being counted down to. */
    label?: string
    /** Shown once the time has passed. */
    doneText?: string
  }>(),
  { label: 'Time left', doneText: 'Ended' },
)

const emit = defineEmits<{
  /** The moment arrived. Sent once. */
  done: []
}>()

defineSlots<{
  /** Draw the time left your own way. */
  default?: (props: TimeLeft) => unknown
}>()

// The clock is only read after mount: a server and a browser never agree on what time it is,
// and text that differs between them breaks hydration. Until then the digits are dashes.
const now = ref<number>()
const target = computed(() => new Date(props.to).getTime())
const left = computed(() => (now.value === undefined ? undefined : timeLeft(target.value - now.value)))
const two = (count: number) => String(count).padStart(2, '0')

let timer: ReturnType<typeof setTimeout> | undefined
let announced = false

function tick() {
  now.value = Date.now()
  if (target.value - now.value <= 0) {
    if (!announced) emit('done')
    announced = true
    return
  }
  // Wake just after the next whole second, so the digits change on the beat instead of drifting.
  timer = setTimeout(tick, 1000 - ((target.value - now.value) % 1000) + 5)
}

onMounted(tick)
watch(target, () => {
  clearTimeout(timer)
  announced = false
  tick()
})
onBeforeUnmount(() => clearTimeout(timer))
</script>

<template>
  <!-- role="timer" is silent by design: a screen reader reads it when asked, and is not interrupted every second. -->
  <span class="er-countdown" role="timer" :aria-label="left ? `${label}: ${left.done ? doneText : timeLeftInWords(left)}` : label">
    <slot v-if="left" v-bind="left">
      <template v-if="left.done">{{ doneText }}</template>
      <span v-else class="er-countdown-parts" aria-hidden="true">
        <span v-if="left.days"><b>{{ left.days }}</b>d</span>
        <span><b>{{ two(left.hours) }}</b>h</span>
        <span><b>{{ two(left.minutes) }}</b>m</span>
        <span><b>{{ two(left.seconds) }}</b>s</span>
      </span>
    </slot>
    <span v-else class="er-countdown-parts" aria-hidden="true">
      <span><b>--</b>h</span>
      <span><b>--</b>m</span>
      <span><b>--</b>s</span>
    </span>
  </span>
</template>

<style>
@layer components {
  .er-countdown {
    display: inline-flex;
    font-size: 0.875rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.25rem;
  }

  .er-countdown-parts {
    display: inline-flex;
    gap: 0.375rem;
  }

  .er-countdown-parts span {
    color: var(--er-muted-foreground);
  }

  .er-countdown-parts b {
    margin-right: 0.0625rem;
    color: var(--er-foreground);
    font-weight: var(--er-weight-semibold);
  }
}
</style>
index.ts 4 lines
/** Countdown — the days, hours, minutes and seconds left until a moment: a sale ending, a cart being held, a session running out. */
export { default as Countdown } from './Countdown.vue'
export { timeLeft, timeLeftInWords, type TimeLeft } from './time'
time.ts 30 lines
export interface TimeLeft {
  days: number
  hours: number
  minutes: number
  seconds: number
  /** Nothing is left. */
  done: boolean
}

/** A length of time in milliseconds, split into days, hours, minutes and seconds. Below zero counts as zero. */
export function timeLeft(milliseconds: number): TimeLeft {
  const total = Math.max(0, Math.floor(milliseconds / 1000))
  return {
    days: Math.floor(total / 86400),
    hours: Math.floor((total % 86400) / 3600),
    minutes: Math.floor((total % 3600) / 60),
    seconds: total % 60,
    done: total === 0,
  }
}

const plural = (count: number, unit: string) => `${count} ${unit}${count === 1 ? '' : 's'}`

/** The time left in words, leaving out leading units that are zero: "2 days, 0 hours, 5 minutes, 9 seconds". */
export function timeLeftInWords(left: TimeLeft): string {
  const parts = [plural(left.days, 'day'), plural(left.hours, 'hour'), plural(left.minutes, 'minute'), plural(left.seconds, 'second')]
  const first = [left.days, left.hours, left.minutes].findIndex((count) => count > 0)
  return parts.slice(first === -1 ? 3 : first).join(', ')
}