A countdown timer with per-unit digit rolls, large card and compact inline variants, a crossfaded completion state, and ticking that pauses in hidden tabs.
npx shadcn@latest add @paragon/countdown"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
/** One rolling digit: the old value falls out, the new drops in. */
function RollDigit({ ch, roll }: { ch: string; roll: boolean }) {
return (
<span
className="relative inline-flex w-[1ch] justify-center overflow-hidden"
aria-hidden
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={ch}
initial={roll ? { y: "-100%", opacity: 0.25 } : false}
animate={{ y: "0%", opacity: 1 }}
exit={
roll
? {
y: "100%",
opacity: 0.25,
transition: { duration: 0.18, ease: EASE_EXIT },
}
: { opacity: 0, transition: { duration: 0 } }
}
transition={{ duration: 0.22, ease: EASE_OUT }}
className="inline-block"
>
{ch}
</motion.span>
</AnimatePresence>
</span>
);
}
function RollNumber({ text, roll }: { text: string; roll: boolean }) {
return (
<span className="inline-flex">
{text.split("").map((ch, i) => (
<RollDigit key={`${text.length}-${i}`} ch={ch} roll={roll} />
))}
</span>
);
}
export interface CountdownProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** The instant being counted down to. */
target?: Date;
/** Base "now". Pass a fixed date for deterministic renders; the
* timer still ticks forward from it. */
now?: Date;
variant?: "large" | "compact";
/** Shown when the countdown reaches zero. */
completeLabel?: string;
onComplete?: () => void;
/** Disables digit-roll motion. */
static?: boolean;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/**
* A countdown with per-unit digit rolls — old digits fall out, new
* ones drop in — in large card and compact inline variants. Ticking
* pauses in hidden tabs and offscreen; hitting zero crossfades into a
* completion state and fires `onComplete` once.
*/
export function Countdown({
target,
now,
variant = "large",
completeLabel = "Time's up",
onComplete,
static: isStatic = false,
className,
...props
}: CountdownProps) {
const reduced = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { amount: 0.1 });
const [fallbackNow] = React.useState(() => new Date());
const base = now ?? fallbackNow;
const baseTime = base.getTime();
const targetTime = target
? target.getTime()
: baseTime + 72 * 3600 * 1000;
const [elapsed, setElapsed] = React.useState(0);
const remaining = Math.max(0, targetTime - (baseTime + elapsed));
const done = remaining === 0;
React.useEffect(() => {
if (!inView || done) return;
const mountedAt = Date.now();
const initial = elapsed; // eslint-disable-line react-hooks/exhaustive-deps
const update = () => setElapsed(initial + (Date.now() - mountedAt));
const id = setInterval(() => {
if (!document.hidden) update();
}, 1000);
const onVisible = () => {
if (!document.hidden) update();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView, done, baseTime, targetTime]);
const completedRef = React.useRef(false);
React.useEffect(() => {
if (done && elapsed > 0 && !completedRef.current) {
completedRef.current = true;
onComplete?.();
}
}, [done, elapsed, onComplete]);
const days = Math.floor(remaining / 86_400_000);
const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
const minutes = Math.floor((remaining % 3_600_000) / 60_000);
const seconds = Math.floor((remaining % 60_000) / 1000);
const roll = !isStatic && !reduced;
const summary = done
? completeLabel
: `${days > 0 ? `${days} ${days === 1 ? "day" : "days"} ` : ""}${hours} hours ${minutes} minutes ${seconds} seconds remaining`;
const units: { label: string; value: string }[] = [
...(days > 0 || variant === "large"
? [{ label: days === 1 ? "Day" : "Days", value: String(days).padStart(2, "0") }]
: []),
{ label: "Hours", value: pad2(hours) },
{ label: variant === "large" ? "Minutes" : "Min", value: pad2(minutes) },
{ label: variant === "large" ? "Seconds" : "Sec", value: pad2(seconds) },
];
return (
<div
ref={rootRef}
role="timer"
aria-label={summary}
className={cn("w-fit", className)}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
{done ? (
<motion.div
key="done"
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn(
"flex items-center gap-2",
variant === "large" ? "h-[68px]" : "h-6",
)}
>
<span
className={cn(
"flex items-center justify-center rounded-full bg-success text-success-foreground",
variant === "large" ? "size-7" : "size-5",
)}
>
<Check
className={variant === "large" ? "size-4" : "size-3"}
aria-hidden
/>
</span>
<span
className={cn(
"font-medium text-foreground",
variant === "large" ? "text-lg" : "text-sm",
)}
>
{completeLabel}
</span>
</motion.div>
) : variant === "large" ? (
<motion.div
key="timer"
exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.15, ease: EASE_EXIT } }}
className="flex items-start gap-1.5"
aria-hidden
>
{units.map((u, i) => (
<React.Fragment key={u.label}>
{i > 0 && (
<span className="pt-[13px] text-xl font-semibold text-muted-foreground/40 select-none">
:
</span>
)}
<span className="flex w-[3.75rem] flex-col items-center gap-1 rounded-lg bg-secondary/60 px-2 pt-2 pb-1.5 dark:bg-secondary/40">
<span className="text-2xl leading-8 font-semibold text-foreground tabular-nums">
<RollNumber text={u.value} roll={roll} />
</span>
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{u.label}
</span>
</span>
</React.Fragment>
))}
</motion.div>
) : (
<motion.div
key="timer"
exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.15, ease: EASE_EXIT } }}
className="flex h-6 items-baseline gap-1 text-sm font-medium text-foreground tabular-nums"
aria-hidden
>
{units.map((u, i) => (
<span key={u.label} className="inline-flex items-baseline">
<RollNumber text={u.value} roll={roll} />
<span className="ml-px text-[11px] font-normal text-muted-foreground">
{u.label.charAt(0).toLowerCase()}
</span>
{i < units.length - 1 && <span className="w-1" />}
</span>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}