Countdown
Time left until a deadline, with ticking digits and a shrinking bar. Both stop at zero.
import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"
// A deadline the demo owns: when it began, when it ends, and two ways to move it. The countdown only shows it.
function useDeadline(seconds: number) {
const [start, setStart] = useState(() => Date.now())
const [end, setEnd] = useState(() => Date.now() + seconds * 1000)
return {
start,
end,
extend: (s: number) => setEnd((e) => Math.max(e, Date.now()) + s * 1000),
restart: () => {
const now = Date.now()
setStart(now)
setEnd(now + seconds * 1000)
},
}
}
// The buttons drive the deadline and are the demo's, not the countdown's: the preview frame sets them in a bar
// along its top edge, apart from the component, and on a page of your own they are two plain buttons.
export default function CountdownDemo() {
const inquiry = useDeadline(30)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
<button type="button" className="rounded-md border border-border px-2 py-1" onClick={() => inquiry.extend(15)}>
Extend by 15 s
</button>
<button type="button" className="rounded-md border border-border px-2 py-1" onClick={inquiry.restart}>
Start again
</button>
</div>
<Countdown expiresAt={inquiry.end} startsAt={inquiry.start} label="Inquiry" className="font-(family-name:--tradecn-font-mono) text-xl" />
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/countdown#v2.0.0
npx shadcn@latest add tradecn/ui/countdown#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/countdown#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/countdown#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ countdown.tsx import { cn } from "cn" import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react" import { useNow } from "@/hooks/use-clock" import { sharedClock, type Clock } from "@/lib/clock" // Time left to a moment: digits that tick once a second and a bar that shrinks to nothing. // // The digits read the shared clock, so a stack of forty countdowns costs one interval. The bar is // one Web Animation on its element, started once and left to the compositor, so it costs nothing per // frame; under reduced motion it steps with the digits instead. The tier turns in the last seconds // and stops at zero. What the countdown never does is decide anything: it shows the time it was // given, and the server's own word is what says whether the thing it timed is over. export type CountdownTier = "plenty" | "soon" | "expired" export interface CountdownThresholds { /** From how far out the tier is `soon`. */ soonMs: number } /** A placeholder until the people who work the screen say how many seconds count as soon. */ export const PROVISIONAL_COUNTDOWN_THRESHOLDS: CountdownThresholds = { soonMs: 10_000 } /** Pure tiering. At or below zero is expired, whatever the thresholds. */ export function countdownTier(remainingMs: number, thresholds: CountdownThresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS): CountdownTier { if (!(remainingMs > 0)) return "expired" return remainingMs <= thresholds.soonMs ? "soon" : "plenty" } /** "1:30", "0:09", "0:00", and "1:02:03" past an hour. Rounds up, so it never says zero while time is left: an endless wait (`Infinity`) prints "–". */ export function formatRemaining(ms: number): string { if (ms === Infinity) return "–" const total = Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 1000)) : 0 const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 const minutes = h > 0 ? String(m).padStart(2, "0") : String(m) return `${h > 0 ? `${h}:` : ""}${minutes}:${String(s).padStart(2, "0")}` } const TIER_CLASS: Record<CountdownTier, string> = { plenty: "text-foreground", soon: "font-semibold text-expiring", expired: "text-muted-foreground", } const BAR_CLASS: Record<CountdownTier, string> = { plenty: "bg-primary", soon: "bg-expiring", expired: "bg-muted-foreground", } function prefersReducedMotion(): boolean { return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches } export interface CountdownProps { /** When it ends, in ms since the epoch. */ expiresAt: number /** When it began, for the bar's full width. Left out, the moment the countdown was first drawn. */ startsAt?: number thresholds?: CountdownThresholds clock?: Clock /** The name a screen reader gives it, and the subject of its announcements. Default "Time left". */ label?: string /** The digits alone, inline, for a grid cell. */ compact?: boolean /** Speak the time left when the tier changes, in a polite live region. Default true; turn it off in a grid, where the stack speaks. */ announce?: boolean /** Called once when the digits reach zero. A display event: the server's word says whether the thing timed is over. */ onExpire?: () => void className?: string } // The capability never changes within a session, so the store is three constants. const subscribeToNothing = () => () => {} const hasWebAnimations = () => typeof Element !== "undefined" && typeof Element.prototype.animate === "function" const optimistic = () => true export function Countdown({ expiresAt, startsAt, thresholds = PROVISIONAL_COUNTDOWN_THRESHOLDS, clock, label = "Time left", compact = false, announce = true, onExpire, className }: CountdownProps) { const c = clock ?? sharedClock() const ticked = useNow(c) // First sight samples the time source directly: the shared clock's last tick can be // arbitrarily old when nothing subscribed while this countdown was away. The time is never // behind first sight, so the first frame shows the real time left and nothing is announced // when the clock catches up. const [firstSeen] = useState(() => c.sample?.() ?? ticked) const now = Math.max(ticked, firstSeen) const remaining = expiresAt - now const tier = countdownTier(remaining, thresholds) // The bar's full width is `startsAt` to `expiresAt`; without a finite `startsAt`, from first sight to the end. // With no finite deadline it does not move: full for an endless wait, empty for one it cannot read. const total = Math.max(1, expiresAt - (typeof startsAt === "number" && Number.isFinite(startsAt) ? startsAt : firstSeen)) const fraction = Number.isFinite(total) ? Math.max(0, Math.min(1, remaining / total)) : remaining > 0 ? 1 : 0 const [reduced] = useState(prefersReducedMotion) // A browser without Web Animations draws the bar from the digits' tick too, instead of holding it // full. The server snapshot is optimistic so hydration agrees, and the client reads the real API. const canAnimate = useSyncExternalStore(subscribeToNothing, hasWebAnimations, optimistic) // Drawn from the digits' tick when it cannot animate: reduced motion, no API, nothing left to animate, or no deadline to run to. const staticBar = reduced || !canAnimate || tier === "expired" || !Number.isFinite(expiresAt) const bar = useRef<HTMLSpanElement>(null) useLayoutEffect(() => { const el = bar.current if (!el || staticBar || typeof el.animate !== "function") return const left = Math.max(0, expiresAt - (c.sample?.() ?? c.now())) const from = Math.max(0, Math.min(1, left / total)) // One animation for exactly the time left, linear, held at zero when it ends. The // compositor runs it. When the fresh sample says time is already up while the last tick // has not caught up — the second right after a deadline — the zero-length run holds the // bar empty instead of leaving it full until the tick. const animation = el.animate([{ transform: `scaleX(${from})` }, { transform: "scaleX(0)" }], { duration: left, easing: "linear", fill: "forwards" }) return () => animation.cancel() }, [expiresAt, total, c, staticBar]) // Said once, when the digits reach zero; said again only if the end moves out and comes back. const latest = useRef(onExpire) useEffect(() => { latest.current = onExpire }) const fired = useRef(false) useEffect(() => { if (tier !== "expired") { fired.current = false return } if (fired.current) return fired.current = true latest.current?.() }, [tier]) // Derived state, set during render: speak when the tier changes, never on a plain tick. const [seen, setSeen] = useState(tier) const [message, setMessage] = useState("") if (seen !== tier) { setSeen(tier) setMessage(`${label} ${formatRemaining(remaining)}`) } // Named by the label and the time left together, so the digits reach a screen reader wherever the name is read, // a grid row's name built from its cells included. const id = useId() return ( <span role="timer" aria-labelledby={`${id}-label ${id}-digits`} data-slot="tradecn-countdown" data-tier={tier} // Soon, the full countdown sits on the expiring tint with foreground digits, which read there in every theme. className={cn(compact ? "inline-flex items-baseline" : "inline-flex min-w-16 flex-col gap-0.5 rounded px-1", "text-xs lining-nums tabular-nums", TIER_CLASS[tier], tier === "soon" && !compact && "bg-expiring-soft text-foreground", className)} > <span id={`${id}-label`} hidden> {label} </span> <span id={`${id}-digits`} data-countdown-digits data-numeric=""> {formatRemaining(remaining)} </span> {!compact && ( <span aria-hidden className="block h-1 w-full overflow-hidden rounded-full bg-muted"> <span ref={bar} className={cn("block h-full w-full origin-left", BAR_CLASS[tier])} style={staticBar ? { transform: `scaleX(${fraction})` } : undefined} /> </span> )} {announce && ( <span aria-live="polite" className="sr-only"> {message} </span> )} </span> ) }hooks/use-clock.ts import { useCallback, useSyncExternalStore } from "react" import { sharedClock, type Clock } from "@/lib/clock" /** The clock's time. The caller re-renders on each tick and on nothing else. The shared one-second clock unless given another. */ export function useNow(clock?: Clock): number { const c = clock ?? sharedClock() // Called through the clock, not detached from it, so a class-based Clock keeps its `this`. const subscribe = useCallback((cb: () => void) => c.subscribe(cb), [c]) const now = useCallback(() => c.now(), [c]) return useSyncExternalStore(subscribe, now, now) }lib/clock.ts // A clock shared by everything that ticks: one interval, running only while someone listens. // // Feed ages and countdowns both want "now" once a second, and a screen with forty of them wants one // timer, not forty. `now()` is the time of the last tick and is stable between ticks, which is what // useSyncExternalStore needs from a snapshot: two reads with no tick between them agree. export interface Clock { subscribe(cb: () => void): () => void /** The time of the last tick; stable between ticks. */ now(): number /** Read the source for an event timestamp without changing the tick snapshot or starting a timer. */ sample?(): number } /** One interval shared by every subscriber; it runs only while someone is listening. An interval that is not a positive number of milliseconds a timer can hold, up to 2^31 − 1, ticks once a second. */ export function createClock(intervalMs = 1000, source: () => number = Date.now): Clock { // A zero, negative, NaN, or overlong interval (a timer holds 2^31 − 1 ms and fires at once past it) would run as // fast as the timer allows and re-render every subscriber each time. const every = intervalMs > 0 && intervalMs <= 2_147_483_647 ? intervalMs : 1000 let current = source() let timer: ReturnType<typeof setInterval> | null = null const listeners = new Set<() => void>() return { now: () => current, sample: source, subscribe(cb) { listeners.add(cb) if (timer === null) { current = source() timer = setInterval(() => { current = source() for (const l of listeners) l() }, every) } return () => { listeners.delete(cb) if (!listeners.size && timer !== null) { clearInterval(timer) timer = null } } }, } } let shared: Clock | null = null /** The one-second clock every tradecn item ticks on unless it is given another. Made on first use. */ export function sharedClock(): Clock { return (shared ??= createClock(1000)) } -
Add the tokens to your stylesheet:
:root { --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); } .dark { --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); }
Usage
import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"
function InquiryCountdown() {
const [receivedAt] = useState(() => Date.now())
return <Countdown expiresAt={receivedAt + 30_000} startsAt={receivedAt} label="Inquiry Q-104" />
}
This example starts a thirty-second timer on mount. In an application, pass the inquiry's received and expiry timestamps from the server, in milliseconds since the epoch. The preview's native buttons extend or restart its sample deadline; the countdown itself only displays it.
Compact
Set compact for the digits alone, inline and without the bar, and give each its own label, since the row's name is not part of the timer's. In a grid or stack that does the speaking, pass announce={false} too. When a row runs out here, the demo hands it a fresh deadline.
import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"
// Three inquiries with the time each has left, the way a stack lists them: the digits alone, no bar, each
// timer named for its row. When one runs out the demo hands its row a fresh deadline, the way the next
// inquiry fills a row on a desk, where the stack takes both from the server.
export default function CountdownCompactDemo() {
const [inquiries, setInquiries] = useState(() => {
const now = Date.now()
return [
{ id: "Q-104", expiresAt: now + 95_000 },
{ id: "Q-105", expiresAt: now + 40_000 },
{ id: "Q-106", expiresAt: now + 12_000 },
]
})
const renew = (id: string) => setInquiries((rows) => rows.map((row) => (row.id === id ? { ...row, expiresAt: Date.now() + 60_000 } : row)))
return (
<ul role="list" aria-label="Inquiries" className="w-48 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
{inquiries.map((inquiry) => (
<li key={inquiry.id} className="flex items-baseline justify-between border-b py-1 last:border-0">
<span>{inquiry.id}</span>
<Countdown expiresAt={inquiry.expiresAt} compact label={`Time left ${inquiry.id}`} onExpire={() => renew(inquiry.id)} />
</li>
))}
</ul>
)
}
Thresholds
thresholds.soonMs sets how far out the soon tier begins, when the bar turns expiring and the digits sit on the expiring tint in the foreground color, or, compact, turn expiring themselves. The same deadline under the default and under a desk that counts the last thirty seconds as soon; the demo starts it over at zero.
import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"
// One deadline under two ideas of soon: the default's ten seconds, and a desk that counts the last thirty
// seconds as soon, so its countdown turns first. When the deadline passes the demo starts it over.
function useDeadline(seconds: number) {
const [start, setStart] = useState(() => Date.now())
return { start, end: start + seconds * 1000, restart: () => setStart(Date.now()) }
}
export default function CountdownThresholdsDemo() {
const deadline = useDeadline(45)
return (
<div className="flex flex-wrap gap-8 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
<div className="space-y-1">
<Countdown expiresAt={deadline.end} startsAt={deadline.start} label="Inquiry, soon at ten seconds" className="text-xl" onExpire={deadline.restart} />
<p className="text-muted-foreground">soon at ten seconds, the default</p>
</div>
<div className="space-y-1">
<Countdown expiresAt={deadline.end} startsAt={deadline.start} thresholds={{ soonMs: 30_000 }} label="Inquiry, soon at thirty seconds" className="text-xl" />
<p className="text-muted-foreground">soon at thirty seconds</p>
</div>
</div>
)
}
Expired
A countdown mounted past its deadline reads 0:00, holds an empty bar, and calls onExpire once; the demo counts the calls. It is a display event, not a verdict (what it does not do).
import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"
// An inquiry that ended before the page drew it: the digits hold at zero, the bar is empty, and onExpire
// fires once on mount. The demo counts the calls; a desk reads the server's status instead.
export default function CountdownExpiredDemo() {
const [over] = useState(() => Date.now() - 1000)
const [calls, setCalls] = useState(0)
return (
<div className="flex flex-col items-center gap-3 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
<Countdown expiresAt={over} startsAt={over - 30_000} label="An earlier inquiry" className="text-xl" onExpire={() => setCalls((n) => n + 1)} />
<p className="text-muted-foreground">
onExpire fired {calls} {calls === 1 ? "time" : "times"}
</p>
</div>
)
}
API Reference
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
expiresAt |
number |
Required | Deadline timestamp. NaN reads as over; Infinity is a wait with no end. |
startsAt |
number |
First render, freshly sampled | Start timestamp used to size the bar. A value that is not a finite time is ignored. |
thresholds |
CountdownThresholds |
{ soonMs: 10_000 } |
When the soon tier begins. |
clock |
Clock |
sharedClock() |
Clock for updates and test timing. |
label |
string |
"Time left" |
Names the timer, with the time left, and leads its announcements. |
compact |
boolean |
false |
Inline digits without a bar. |
announce |
boolean |
true |
Announce tier changes to screen readers. |
onExpire |
() => void |
None | Called on expiry, including an already-expired mount. Can fire again after the deadline is extended. |
className |
string |
None | CSS classes for the root timer element. |
Tiers
The root's data-tier tracks the time left:
| Tier | Time left |
|---|---|
plenty |
Above thresholds.soonMs |
soon |
At or below thresholds.soonMs, but above zero |
expired |
Zero or less |
The default, PROVISIONAL_COUNTDOWN_THRESHOLDS, is a placeholder. Set thresholds.soonMs with the people using the screen. For row colors or sorting, countdownTier(remainingMs, thresholds) gives the same tier without rendering a component.
formatRemaining(ms) rounds up to whole seconds: 1:30, 0:09, 0:00, or 1:02:03 past an hour. It never shows zero while time remains, and prints – for Infinity.
The bar
Pass startsAt so the full bar represents the time from start to expiry. Without it, the bar starts full on first render if time remains, even for a countdown restored partway through.
One linear Web Animation shrinks the bar to zero, with no JavaScript work per frame. Changing expiresAt cancels and restarts it. Under prefers-reduced-motion, as read on mount, or in a browser without Web Animations, the bar steps with the digits instead. A deadline that isn't a finite time holds the bar still: empty for NaN, full for Infinity.
The clock
Countdowns and feed-health share one timer: sharedClock() from @/lib/clock, ticking once a second. Each countdown subscribes independently; ticks don't re-render its parents.
The clock's now() returns its last tick, which refreshes while anything subscribes. On a clock with sample, as createClock makes, a countdown samples the time source when it is first drawn and is never behind that moment, so one mounted after the clock sat idle shows the real time left from its first frame and announces nothing as the clock catches up. That moment stays when clock changes; change the countdown's key to start it again. Pass a faster clock for finer timing, or createClock(1000, () => t) for tests. createClock ticks once a second for an interval that is not a positive number of milliseconds up to 2³¹ − 1, the most a timer holds.
Render countdowns on the client. The shared clock advances only while something in a browser subscribes, so server-rendered digits, tiers, and data-tier go stale and mismatch on hydration.
What it says
A polite live region announces tier changes, such as "Inquiry 0:10" and "Inquiry 0:00", without speaking on every tick. Set announce={false} when the containing grid or stack handles announcements.
The timer's accessible name is its label and the time left, so the time is read wherever the name is. A grid row named by its cells would change its name at every tick, so name such rows with the grid's getRowLabel, as rfq-stack does; the grid reads the focused row's cells, the timer's name among them, once focus rests on the row.
What it does not do
onExpire is a display event. Use the server's status to decide whether an inquiry has expired. The component only displays time left; it doesn't count up or past zero.
Tokens
The soon tier uses expiring and expiring-soft. Installation adds missing tokens; theme items set their values.
This page is docs/countdown.md at v2.0.0.