Countdown Timer
Count-down-to-date timer with boxed / inline / minimal variants, a useCountdown hook, digit animation and completion content.
Example
Installation
npx shadcn@latest add https://hirael.com/r/countdown-timer.jsonAPI
<CountdownTimer />
+ native element props| Prop | Type | Default |
|---|---|---|
target* | string | number | Date | — |
variant | "inline" | "boxed" | "minimal" | "boxed" |
hideZeroDays | boolean | false |
labels | Partial<Record<CountdownUnit, string>> | — |
onComplete | (() => void) | — |
completeContent | React.ReactNode | — |
children | ((state: CountdownState) => React.ReactNode) | — |
<CountdownTimerUnit />
+ native element props| Prop | Type | Default |
|---|---|---|
value* | number | null | — |
label | string | — |
Component source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type CountdownUnit = "days" | "hours" | "minutes" | "seconds";
export type CountdownState = {
days: number;
hours: number;
minutes: number;
seconds: number;
totalMs: number;
isComplete: boolean;
};
export type UseCountdownOptions = {
onComplete?: () => void;
};
const toTimestamp = (target: Date | string | number) =>
target instanceof Date ? target.getTime() : new Date(target).getTime();
const getCountdownState = (targetMs: number): CountdownState => {
const totalMs = Math.max(targetMs - Date.now(), 0);
if (!Number.isFinite(totalMs)) {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
totalMs: 0,
isComplete: true,
};
}
const totalSeconds = Math.floor(totalMs / 1000);
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
totalMs,
isComplete: totalMs <= 0,
};
};
function useCountdown(
target: Date | string | number,
options: UseCountdownOptions = {},
): CountdownState {
const targetMs = toTimestamp(target);
const [state, setState] = React.useState<CountdownState>(() =>
getCountdownState(targetMs),
);
const onCompleteRef = React.useRef(options.onComplete);
React.useEffect(() => {
onCompleteRef.current = options.onComplete;
});
React.useEffect(() => {
let fired = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const tick = () => {
const next = getCountdownState(targetMs);
setState(next);
if (next.isComplete) {
if (!fired) {
fired = true;
onCompleteRef.current?.();
}
return;
}
// Align the next tick to the upcoming second boundary.
timeout = setTimeout(tick, 1000 - (Date.now() % 1000));
};
tick();
return () => {
if (timeout !== undefined) clearTimeout(timeout);
};
}, [targetMs]);
return state;
}
const useMounted = () => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted;
};
const useReducedMotion = () => {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(query.matches);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
};
function CountdownTimerValue({
value,
className,
...props
}: React.ComponentProps<"span"> & { value: string }) {
const reduceMotion = useReducedMotion();
return (
<span
data-slot="countdown-timer-value"
className={cn(
"inline-flex overflow-hidden font-mono tabular-nums",
className,
)}
{...props}
>
<span
key={reduceMotion ? "static" : value}
className={cn(
!reduceMotion &&
"animate-in fade-in slide-in-from-top-2 duration-300",
)}
>
{value}
</span>
</span>
);
}
export type CountdownTimerUnitProps = Omit<
React.ComponentProps<"div">,
"children"
> & {
value: number | null;
label?: string;
};
function CountdownTimerUnit({
value,
label,
className,
...props
}: CountdownTimerUnitProps) {
const display = value === null ? "--" : String(value).padStart(2, "0");
return (
<div
data-slot="countdown-timer-unit"
className={cn("flex flex-col items-center gap-0.5", className)}
{...props}
>
<CountdownTimerValue value={display} />
{label ? (
<span
data-slot="countdown-timer-label"
className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground"
>
{label}
</span>
) : null}
</div>
);
}
const defaultLabels: Record<CountdownUnit, string> = {
days: "Days",
hours: "Hours",
minutes: "Min",
seconds: "Sec",
};
const unitOrder: CountdownUnit[] = ["days", "hours", "minutes", "seconds"];
export type CountdownTimerProps = Omit<
React.ComponentProps<"div">,
"children"
> & {
target: Date | string | number;
variant?: "boxed" | "inline" | "minimal";
hideZeroDays?: boolean;
labels?: Partial<Record<CountdownUnit, string>>;
onComplete?: () => void;
completeContent?: React.ReactNode;
children?: (state: CountdownState) => React.ReactNode;
};
function CountdownTimer({
target,
variant = "boxed",
hideZeroDays = false,
labels,
onComplete,
completeContent,
children,
className,
...props
}: CountdownTimerProps) {
const state = useCountdown(target, { onComplete });
const mounted = useMounted();
const resolvedLabels = { ...defaultLabels, ...labels };
const units =
hideZeroDays && state.days === 0 ? unitOrder.slice(1) : unitOrder;
const valueOf = (unit: CountdownUnit) => (mounted ? state[unit] : null);
let content: React.ReactNode;
if (children) {
content = children(state);
} else if (mounted && state.isComplete && completeContent !== undefined) {
content = completeContent;
} else if (variant === "boxed") {
content = units.map((unit) => (
<CountdownTimerUnit
key={unit}
value={valueOf(unit)}
label={resolvedLabels[unit]}
className="min-w-16 rounded-md border border-border bg-card p-3 text-2xl font-semibold tracking-tight"
/>
));
} else if (variant === "inline") {
content = units.map((unit, index) => (
<React.Fragment key={unit}>
{index > 0 ? (
<span
data-slot="countdown-timer-separator"
aria-hidden
className="font-mono text-xl font-medium text-muted-foreground"
>
:
</span>
) : null}
<CountdownTimerUnit
value={valueOf(unit)}
label={resolvedLabels[unit]}
className="text-xl font-semibold"
/>
</React.Fragment>
));
} else {
content = (
<span
data-slot="countdown-timer-display"
className="font-mono tabular-nums"
>
{units.map((unit, index) => (
<React.Fragment key={unit}>
{index > 0 ? <span aria-hidden>:</span> : null}
<CountdownTimerValue
value={
valueOf(unit) === null
? "--"
: String(state[unit]).padStart(2, "0")
}
/>
</React.Fragment>
))}
</span>
);
}
return (
<div
data-slot="countdown-timer"
role="timer"
className={cn(
"flex items-start gap-2",
variant === "minimal" && "inline-flex gap-0",
className,
)}
{...props}
>
{content}
</div>
);
}
export { CountdownTimer, CountdownTimerUnit, useCountdown };