Rolling-digit countdown that sleeps off screen and lands in an ended state.
01Preview
Launch week starts in
2 seats held
Row F, 11–12 · Finish checkout to keep them
Code sent to maya@northwind.io
Resend in02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @number-flow/react motion03Usage
import { Countdown } from "@/components/ui/countdown";
<Countdown to="2026-10-01T17:00:00Z" label="Sale ends in" ended="Sale ended" />
// Inline, warning in the last minute, and a button once it runs out.
<Countdown
to={heldUntil}
variant="compact"
warnBelow={60}
ended={<button onClick={resend}>Resend code</button>}
/>04Source
"use client";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
type DateInput = Date | string | number;
type Unit = "day" | "hour" | "minute" | "second";
const toTime = (d: DateInput) => (d instanceof Date ? d.getTime() : typeof d === "number" ? d : Date.parse(d));
const noop = () => () => {};
const stamp = () => ({ now: Date.now() });
function useLocale(locale?: string) {
const detected = useSyncExternalStore(
noop,
() => Intl.DateTimeFormat().resolvedOptions().locale,
() => "en-US",
);
return locale ?? detected;
}
export type CountdownParts = { days: number; hours: number; minutes: number; seconds: number };
/**
* Milliseconds left until `to`, ticking on the exact second boundary of the
* target. While `paused` (off screen, tab hidden) it stops ticking but still
* wakes once at the end, so completion is never missed. null on the server.
*/
export function useCountdown(to: DateInput, { paused = false }: { paused?: boolean } = {}) {
const at = toTime(to);
const [clock] = useState(stamp);
const subscribe = useCallback(
(notify: () => void) => {
let timer = 0;
const tick = () => {
window.clearTimeout(timer);
clock.now = Date.now();
notify();
const left = at - clock.now;
if (Number.isNaN(left) || left <= 0) return;
// Asleep: skip the seconds and wake only for the finish.
const next = paused || document.hidden ? left : left % 1000 || 1000;
timer = window.setTimeout(tick, next + 2);
};
tick();
document.addEventListener("visibilitychange", tick);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", tick);
};
},
[clock, at, paused],
);
const now = useSyncExternalStore(
subscribe,
() => clock.now,
() => null,
);
return useMemo(() => {
if (now == null || Number.isNaN(at)) return { remaining: null, parts: null, ended: false };
const remaining = Math.max(0, at - now);
const total = Math.ceil(remaining / 1000);
const parts: CountdownParts = {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
};
return { remaining, parts, ended: remaining === 0 };
}, [now, at]);
}
/** "2 days, 4 hours, 12 minutes" for screen readers, to the minute (or ten seconds near the end). */
function spoken(locale: string, p: CountdownParts) {
const total = p.days * 86400 + p.hours * 3600 + p.minutes * 60 + p.seconds;
const unit = (u: Unit, n: number) => new Intl.NumberFormat(locale, { style: "unit", unit: u, unitDisplay: "long" }).format(n);
const list: string[] =
total < 60
? [unit("second", Math.ceil(total / 10) * 10 || total)]
: [p.days && unit("day", p.days), p.hours && unit("hour", p.hours), p.minutes && unit("minute", p.minutes)].filter((x): x is string => !!x);
return new Intl.ListFormat(locale, { style: "long", type: "unit" }).format(list);
}
/** The word for a unit, plural-aware and in the reader's language: "days", "hour", "Minuten". */
function unitWord(locale: string, u: Unit, n: number, display: "long" | "narrow") {
const parts = new Intl.NumberFormat(locale, { style: "unit", unit: u, unitDisplay: display }).formatToParts(n);
return parts.find((p) => p.type === "unit")?.value ?? u;
}
const expo = `cubic-bezier(${ease.out.join(",")})`;
const timing = {
transformTiming: { duration: 550, easing: expo },
spinTiming: { duration: 550, easing: expo },
opacityTiming: { duration: 200, easing: "ease-out" },
};
export type CountdownProps = Omit<React.ComponentProps<"div">, "children"> & {
/** When it ends: a Date, a timestamp in ms, or an ISO string. */
to: DateInput;
/** Tiles with unit labels, or one line of text that sits in a sentence. */
variant?: "block" | "compact";
/** Seconds left at which it turns to the warning color, for holds and expiring offers. */
warnBelow?: number;
/** What replaces the digits once it ends. */
ended?: React.ReactNode;
/** Called once, when it reaches zero while mounted. */
onComplete?: () => void;
/** Accessible name, read before the time: "Sale ends in". */
label?: string;
locale?: string;
};
export function Countdown({
to,
variant = "block",
warnBelow,
ended: endedContent = "Ended",
onComplete,
label,
locale: localeProp,
className,
ref,
...rest
}: CountdownProps) {
const locale = useLocale(localeProp);
const reduce = useReducedMotion();
const [el, setEl] = useState<HTMLDivElement | null>(null);
const [onScreen, setOnScreen] = useState(true);
const { parts, ended, remaining } = useCountdown(to, { paused: !onScreen });
const [announce, setAnnounce] = useState(false);
const wasEnded = useRef<boolean | null>(null);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
setEl(node);
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
// Off screen, the digits stop; they catch up with a roll when scrolled back.
useEffect(() => {
if (!el) return;
const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [el]);
// onComplete fires on the live transition only, not when mounting already past.
useEffect(() => {
if (remaining == null) return;
if (wasEnded.current === false && ended) {
onComplete?.();
setAnnounce(true);
}
wasEnded.current = ended;
}, [ended, remaining, onComplete]);
const warn = warnBelow != null && remaining != null && !ended && remaining <= warnBelow * 1000;
const state = remaining == null ? "pending" : ended ? "ended" : "running";
const numbers = (compact: boolean) => {
if (!parts) return null;
const units: { u: Unit; n: number; max?: number }[] = [];
if (parts.days > 0) units.push({ u: "day", n: parts.days });
if (!compact || parts.days > 0 || parts.hours > 0) units.push({ u: "hour", n: parts.hours, max: 2 });
units.push({ u: "minute", n: parts.minutes, max: 5 }, { u: "second", n: parts.seconds, max: 5 });
return units;
};
const flow = (n: number, u: Unit, max: number | undefined, pad: boolean) => (
<NumberFlow
value={n}
locales={locale}
format={{ minimumIntegerDigits: pad ? 2 : 1, useGrouping: false }}
digits={max != null ? { 1: { max } } : undefined}
trend={-1}
animated={!reduce}
data-unit={u}
{...timing}
/>
);
const running =
variant === "compact" ? (
// NumberFlow's mask is taller than the line; a line-high box keeps it level with the text beside it.
<span className="inline-flex h-[1lh] items-center">
<span className="inline-flex items-baseline whitespace-nowrap">
{(numbers(true) ?? []).map(({ u, n, max }, i, list) => {
const prev = list[i - 1]?.u;
// "2d 04:12:09", "4:12:09", "0:42": the leading unit is unpadded, the rest are two digits.
return (
<span key={u} className="inline-flex items-baseline">
{prev === "day" ? <span className="w-[0.3em]" /> : prev ? <span className="px-px text-fg-4">:</span> : null}
{flow(n, u, max, u !== "day" && !!prev)}
{u === "day" && <span className="text-fg-3">{unitWord(locale, "day", n, "narrow")}</span>}
</span>
);
})}
{!parts && <span className="text-fg-4">–:––</span>}
</span>
</span>
) : (
<span className="flex items-start gap-1.5">
{(numbers(false) ?? (["hour", "minute", "second"] as Unit[]).map((u) => ({ u, n: -1, max: undefined }))).map(({ u, n, max }, i) => (
<motion.span key={u} layout={!reduce} transition={{ duration: 0.3, ease: ease.inOut }} className="flex items-start gap-1.5">
{i > 0 && (
<span aria-hidden className="flex h-14 items-center text-[22px] text-fg-4">
:
</span>
)}
<span className="flex flex-col items-center gap-1.5">
<span
className={cn(
"flex h-14 min-w-14 items-center justify-center rounded-lg border bg-raised px-2 text-[28px] font-medium tracking-[-0.02em] shadow-[var(--shadow)]",
"transition-[border-color,color] duration-300",
warn ? "border-warning/40 text-warning" : "border-line text-fg",
)}
>
{n < 0 ? <span className="text-fg-4">––</span> : flow(n, u, max, true)}
</span>
<span className="font-mono text-2xs uppercase tracking-[0.08em] text-fg-3">{unitWord(locale, u, n < 0 ? 2 : n, "long")}</span>
</span>
</motion.span>
))}
</span>
);
const swap = {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(2px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.28, ease: ease.out, delay: 0.06 } },
exit: reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, scale: 0.98, filter: "blur(2px)", transition: { duration: 0.18, ease: ease.in } },
};
return (
<div
ref={setRefs}
role="timer"
aria-label={label}
data-state={state}
data-variant={variant}
data-warn={warn || undefined}
className={cn(
"grid tabular",
// The block keeps its height when the tiles give way to the ended content.
variant === "compact" ? "inline-grid align-baseline" : "min-h-[76px] items-center justify-items-center",
variant === "compact" && (warn ? "text-warning" : ""),
"transition-colors duration-300",
className,
)}
{...rest}
>
<AnimatePresence initial={false}>
{ended ? (
<motion.span key="ended" className="col-start-1 row-start-1 flex items-center self-center" {...swap}>
{endedContent}
</motion.span>
) : (
<motion.span key="running" aria-hidden className="col-start-1 row-start-1" {...swap}>
<NumberFlowGroup>{running}</NumberFlowGroup>
</motion.span>
)}
</AnimatePresence>
{!ended && parts && <span className="sr-only">{spoken(locale, parts)}</span>}
<span role="status" aria-live="polite" className="sr-only">
{announce && typeof endedContent === "string" ? endedContent : ""}
</span>
</div>
);
}05Props
Countdown
| Prop | Type | Default | Description |
|---|---|---|---|
| to* | Date | string | number | — | When it ends: a Date, a timestamp in ms, or an ISO string. |
| variant | "block" | "compact" | "block" | Tiles with localized unit labels, or one line of text (2d 04:12:09, 4:12:09, 0:42) that sits in a sentence. |
| warnBelow | number | — | Seconds left at which the digits turn to the warning color. |
| ended | React.ReactNode | "Ended" | What replaces the digits at zero, such as a message or the next action. |
| onComplete | () => void | — | Called once when it reaches zero while mounted. Not called when it mounts already past. |
| label | string | — | Accessible name of the timer, read before the time: “Sale ends in”. |
| locale | string | — | BCP 47 locale for digits, unit labels and the spoken time. Defaults to the reader's. |
useCountdown
| Prop | Type | Default | Description |
|---|---|---|---|
| to* | Date | string | number | — | First argument. Returns { remaining, parts, ended }, with remaining in ms and null on the server. |
| paused | boolean | false | Stops the per-second ticks but still wakes once at the end, so completion is never missed. |
06Notes
Behavior
- Time is always computed from the target, never counted down, so it can't drift. Ticks land on the target's own second boundary.
- Off screen or in a hidden tab it stops ticking, but keeps one timer for the finish so onComplete still fires on time. Scrolled back, the digits roll to the right value.
- Days appear only while there are days left; the compact line also drops hours under an hour, so an OTP resend reads 0:42, not 00:00:42.
- The block keeps its height when the tiles give way to the ended content, so nothing below it jumps.
- Hydration safe: the server renders dashes, the client fills in the real time right after hydration.
Motion
- Each tick rolls only the digits that changed, downward, in 550ms on the expo ease-out (NumberFlow in a group so the columns move together). Tens of minutes and seconds wrap 0 to 5, not 0 to 9.
- At zero the digits leave with a 2px blur and a 0.98 scale in 180ms, and the ended content rises 4px into place in 280ms after a 60ms pause.
- The warning color fades in over 300ms. Reduced motion changes digits in place and crossfades the ended state.
Accessibility
- role=timer, named by label. The visual digits are hidden from assistive tech and a quieter text says “2 days, 4 hours, 12 minutes”, updated to the minute.
- A polite live region announces a text ended state once, when it happens.
- When the ended content is a button, keep focus sensible after it's used; the demo moves it to the row so it isn't dropped on the page.