"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export type CountdownFormat = "full" | "compact";
type Unit = "day" | "hour" | "minute" | "second";
export interface CountdownProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** Moment to count down to: a Date, an ISO string or epoch milliseconds. */
target: Date | string | number;
/** Large segments with unit labels, or a single inline line. */
format?: CountdownFormat;
/** Show a seconds segment. */
showSeconds?: boolean;
/** Progress ring around each segment (full format). */
rings?: boolean;
/** Roll digits when they change. */
roll?: boolean;
size?: "sm" | "md" | "lg";
/** BCP 47 locale for unit labels and the accessible summary. */
locale?: string;
/** Days that make a full days ring. */
dayRange?: number;
/** Text shown (and announced) once the countdown completes. */
completedText?: string;
/** Accessible name of the timer. */
label?: string;
/** Accent colour (any CSS colour). */
accent?: string;
/** Called once, after mount, when the remaining time reaches zero (also if the target is already past). */
onComplete?: () => void;
}
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- ticker --------------------------------- */
/**
* One shared second-aligned clock for every countdown on the page. It only runs while
* something is subscribed and the tab is visible; the server snapshot is null, so the server
* (and the hydration pass) render a placeholder and ticking starts after mount.
*/
const ticker = (() => {
const listeners = new Set<() => void>();
let now = 0;
let timer = 0;
const read = () => Math.floor(Date.now() / 1000);
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(tick, 1000 - (Date.now() % 1000) + 8);
};
const tick = () => {
now = read();
listeners.forEach((l) => l());
schedule();
};
const onVisibility = () => {
if (document.hidden) {
window.clearTimeout(timer);
timer = 0;
} else tick();
};
return {
subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) {
now = read();
document.addEventListener("visibilitychange", onVisibility);
if (!document.hidden) schedule();
}
return () => {
listeners.delete(listener);
if (!listeners.size) {
window.clearTimeout(timer);
timer = 0;
document.removeEventListener("visibilitychange", onVisibility);
}
};
},
// Without subscribers the cached value may be stale, so refresh it lazily.
getSnapshot: () => (listeners.size ? now : (now = read())),
};
})();
const noopSubscribe = () => () => {};
const getServerSecond = () => null;
/* --------------------------------- helpers -------------------------------- */
function toMs(target: Date | string | number) {
const ms = target instanceof Date ? target.getTime() : typeof target === "number" ? target : Date.parse(target);
return Number.isFinite(ms) ? ms : 0;
}
/** Localised unit name ("days", "Tage", "hari") taken from Intl's unit formatting. */
function unitName(locale: string, unit: Unit, value: number, display: "long" | "narrow") {
try {
const parts = new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: display }).formatToParts(value);
return parts
.filter((p) => p.type === "unit")
.map((p) => p.value)
.join("")
.trim();
} catch {
return unit;
}
}
function summaryText(locale: string, parts: { unit: Unit; value: number }[]) {
const items = parts
.filter((p) => p.value > 0)
.map((p) => {
try {
return new Intl.NumberFormat(locale, { style: "unit", unit: p.unit, unitDisplay: "long" }).format(p.value);
} catch {
return `${p.value} ${p.unit}s`;
}
});
if (!items.length) return "";
try {
return new Intl.ListFormat(locale, { type: "conjunction" }).format(items);
} catch {
return items.join(", ");
}
}
const EASE = [0.22, 1, 0.36, 1] as const;
const SIZES = {
sm: { box: "size-14", digits: "text-xl", label: "text-[9px]" },
// Four segments must fit a 320px phone in one row, so md and lg start smaller.
md: { box: "size-14 sm:size-[76px]", digits: "text-xl sm:text-3xl", label: "text-[9px] sm:text-[10px]" },
lg: { box: "size-[60px] sm:size-28", digits: "text-2xl sm:text-5xl", label: "text-[9px] sm:text-[10px]" },
} as const;
/** A digit that rolls in from above as the countdown decreases. */
function RollDigit({ char, roll }: { char: string; roll: boolean }) {
return (
<span className="relative inline-block overflow-hidden leading-[1.15]">
<span className="invisible">0</span>
<AnimatePresence initial={false}>
<motion.span
key={char}
className="absolute inset-0 text-center"
initial={roll ? { y: "-100%", opacity: 0 } : false}
animate={{ y: "0%", opacity: 1 }}
exit={roll ? { y: "100%", opacity: 0 } : { opacity: 0, transition: { duration: 0 } }}
transition={{ duration: roll ? 0.5 : 0, ease: EASE }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
}
function Digits({ text, roll }: { text: string; roll: boolean }) {
return (
<span className="inline-flex tabular-nums">
{Array.from(text).map((char, i) => (
// Keyed from the right so the ones digit keeps its identity when a value loses a digit.
<RollDigit key={text.length - i} char={char} roll={roll} />
))}
</span>
);
}
/**
* A countdown to a date with rolling digits, optional progress rings and localised unit labels.
* Ticks from one shared second-aligned clock, only after mount (so SSR never mismatches), and
* stays quiet for screen readers: role="timer" with a minute-level summary.
*/
export function Countdown({
target,
format = "full",
showSeconds = true,
rings = true,
roll = true,
size = "md",
locale = "en-US",
dayRange = 30,
completedText = "Time’s up",
label = "Countdown",
accent = "#FF7448",
onComplete,
className = "",
style,
...props
}: CountdownProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const completedRef = React.useRef(false);
const [inView, setInView] = React.useState(true);
// Offscreen countdowns stop re-rendering; the time is recomputed from the clock when they return.
const subscribe = inView ? ticker.subscribe : noopSubscribe;
const nowSec = React.useSyncExternalStore(subscribe, ticker.getSnapshot, getServerSecond);
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)));
observer.observe(el);
return () => observer.disconnect();
}, []);
const targetSec = Math.floor(toMs(target) / 1000);
const mounted = nowSec !== null;
const remaining = mounted ? Math.max(0, targetSec - nowSec) : null;
const completed = remaining === 0;
React.useEffect(() => {
if (completed && !completedRef.current) {
completedRef.current = true;
onComplete?.();
} else if (!completed && remaining !== null) {
completedRef.current = false;
}
}, [completed, remaining, onComplete]);
const r = remaining ?? 0;
const values: { unit: Unit; value: number; max: number }[] = [
{ unit: "day", value: Math.floor(r / 86400), max: Math.max(1, dayRange) },
{ unit: "hour", value: Math.floor((r % 86400) / 3600), max: 24 },
{ unit: "minute", value: Math.floor((r % 3600) / 60), max: 60 },
{ unit: "second", value: r % 60, max: 60 },
];
const shown = showSeconds ? values : values.slice(0, 3);
// Minute-level summary: changes at most once per minute.
const minutesLeft = remaining === null ? null : Math.ceil(remaining / 60);
const summary =
minutesLeft === null
? ""
: completed
? completedText
: minutesLeft <= 1
? `${summaryText(locale, [{ unit: "minute", value: 1 }])} or less remaining`
: `${summaryText(locale, [
{ unit: "day", value: Math.floor(minutesLeft / 1440) },
{ unit: "hour", value: Math.floor((minutesLeft % 1440) / 60) },
{ unit: "minute", value: minutesLeft % 60 },
])} remaining`;
const shouldRoll = roll && !reduceMotion;
const sizes = SIZES[size] ?? SIZES.md;
const pad = (v: number) => String(v).padStart(2, "0");
return (
<div
ref={rootRef}
role="timer"
aria-live="off"
aria-atomic="true"
aria-label={label}
data-completed={completed ? "" : undefined}
className={`inline-flex flex-col items-center gap-3 text-zinc-900 dark:text-zinc-50 ${className}`}
style={{ ["--cd-accent" as string]: accent, ...style }}
{...props}
>
{/* Read on demand by screen readers; never announced every second. */}
<span className="sr-only">{mounted ? summary : "Loading countdown"}</span>
{format === "compact" ? (
<span
aria-hidden="true"
className={`inline-flex items-baseline gap-2 font-mono text-sm tabular-nums tracking-[0.02em] ${
completed ? "text-[var(--cd-ink)]" : ""
} [--cd-ink:color-mix(in_oklab,var(--cd-accent)_78%,#18181b)] dark:[--cd-ink:var(--cd-accent)]`}
>
{shown.map(({ unit, value }) => (
<span key={unit} className="inline-flex items-baseline gap-px">
{mounted ? <Digits text={pad(value)} roll={shouldRoll} /> : <span>––</span>}
<span className="text-zinc-500 dark:text-zinc-400">{unitName(locale, unit, mounted ? value : 2, "narrow")}</span>
</span>
))}
</span>
) : (
<span aria-hidden="true" className="flex flex-wrap items-start justify-center gap-2 sm:gap-3">
{shown.map(({ unit, value, max }, i) => {
const progress = Math.min(1, value / max);
// Key rings by the next-larger total, so a wrap (0 → 59) remounts instead of sweeping backwards.
const wrapKey = i === 0 ? "d" : Math.floor(r / [86400, 86400, 3600, 60][i]);
return (
<span key={unit} className="flex flex-col items-center gap-2">
<span
className={`relative flex items-center justify-center rounded-full font-semibold tracking-[-0.03em] ${sizes.box} ${sizes.digits} ${
rings ? "" : "rounded-xl border border-zinc-900/10 bg-white/60 dark:border-white/10 dark:bg-white/[0.03]"
} ${completed ? "text-[var(--cd-accent)]" : ""}`}
>
{rings ? (
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full -rotate-90">
<circle cx="50" cy="50" r="46" fill="none" strokeWidth="2" className="stroke-zinc-900/10 dark:stroke-white/10" />
{mounted ? (
<circle
key={wrapKey}
cx="50"
cy="50"
r="46"
fill="none"
strokeWidth="2.5"
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
className="stroke-[var(--cd-accent)]"
style={{
strokeDashoffset: 1 - progress,
transition: shouldRoll ? "stroke-dashoffset 1s linear" : undefined,
}}
/>
) : null}
</svg>
) : null}
<span className="relative">{mounted ? <Digits text={pad(value)} roll={shouldRoll} /> : "––"}</span>
</span>
<span className={`font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400 ${sizes.label}`}>
{unitName(locale, unit, mounted ? value : 2, "long")}
</span>
</span>
);
})}
</span>
)}
{/* Always present, so the completion message is announced when it is filled in. */}
<span role="status" className="sr-only">
{completed ? completedText : ""}
</span>
{completed && completedText ? (
<span
aria-hidden="true"
className="inline-flex items-center gap-2 rounded-full border border-[var(--cd-accent)]/40 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 dark:text-zinc-200"
>
<span aria-hidden="true" className="size-1.5 rounded-full bg-[var(--cd-accent)]" />
{completedText}
</span>
) : null}
</div>
);
}