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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add countdownpnpm dlx ermineui add countdownyarn dlx ermineui add countdownbunx ermineui add countdownThat 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
| File | Where it goes |
|---|---|
Countdown.vue | src/components/ui/countdown/Countdown.vue |
index.ts | src/components/ui/countdown/index.ts |
time.ts | src/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(', ')
}