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.jsonUsage
import { CountdownTimer, CountdownTimerUnit, useCountdown } from "@/components/countdown-timer"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<CountdownTimer />
+ native element props| Prop | Type | Default |
|---|---|---|
target* | string | number | Date | none |
variant | "boxed" | "inline" | "minimal" | 'boxed' |
hideZeroDays | boolean | false |
labelsOverride any unit label; unset units fall back to the English defaults. | Partial<Record<CountdownUnit, string>> | none |
onComplete | (() => void) | none |
completeContent | React.ReactNode | none |
children | ((state: CountdownState) => React.ReactNode) | none |
<CountdownTimerUnit />
+ native element props| Prop | Type | Default |
|---|---|---|
value* | number | null | none |
label | string | none |
Component source
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { useReducedMotion } from '@/registry/hirael/hooks/use-reduced-motion';
export type CountdownUnit = 'days' | 'hours' | 'minutes' | 'seconds';
export interface CountdownState {
days: number;
hours: number;
minutes: number;
seconds: number;
totalMs: number;
isComplete: boolean;
}
export interface UseCountdownOptions {
onComplete?: () => void;
}
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,
};
};
/** Returns `null` until mounted, so server and hydration renders never read the clock. */
const useCountdown = (target: Date | string | number, options: UseCountdownOptions = {}): CountdownState | null => {
const targetMs = new Date(target).getTime();
const [state, setState] = React.useState<CountdownState | null>(null);
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;
}
timeout = setTimeout(tick, next.totalMs % 1000 || 1000);
};
tick();
return () => {
if (timeout !== undefined) clearTimeout(timeout);
};
}, [targetMs]);
return state;
};
const 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 tabular-nums', className)}
{...props}
>
<span
key={reduceMotion ? 'static' : value}
className={cn(!reduceMotion && 'animate-in duration-300 fade-in slide-in-from-top-2')}
>
{value}
</span>
</span>
);
};
export interface CountdownTimerUnitProps extends Omit<React.ComponentProps<'div'>, 'children'> {
value: number | null;
label?: string;
}
const pad = (value: number | null) => (value === null ? '--' : String(value).padStart(2, '0'));
const CountdownTimerUnit = ({ value, label, className, ...props }: CountdownTimerUnitProps) => {
return (
<div data-slot="countdown-timer-unit" className={cn('flex flex-col items-center gap-0.5', className)} {...props}>
<CountdownTimerValue value={pad(value)} />
{label && (
<span
data-slot="countdown-timer-label"
className="text-[10px] font-medium tracking-[0.1em] text-muted-foreground uppercase"
>
{label}
</span>
)}
</div>
);
};
const defaultLabels: Record<CountdownUnit, string> = {
days: 'Days',
hours: 'Hours',
minutes: 'Min',
seconds: 'Sec',
};
const unitOrder: CountdownUnit[] = ['days', 'hours', 'minutes', 'seconds'];
export interface CountdownTimerProps extends Omit<React.ComponentProps<'div'>, 'children'> {
target: Date | string | number;
variant?: 'boxed' | 'inline' | 'minimal';
hideZeroDays?: boolean;
/** Override any unit label; unset units fall back to the English defaults. */
labels?: Partial<Record<CountdownUnit, string>>;
onComplete?: () => void;
completeContent?: React.ReactNode;
children?: (state: CountdownState) => React.ReactNode;
}
const CountdownTimer = ({
target,
variant = 'boxed',
hideZeroDays = false,
labels,
onComplete,
completeContent,
children,
className,
...props
}: CountdownTimerProps) => {
const state = useCountdown(target, { onComplete });
const resolvedLabels = { ...defaultLabels, ...labels };
const units = hideZeroDays && state?.days === 0 ? unitOrder.slice(1) : unitOrder;
const valueOf = (unit: CountdownUnit) => (state ? state[unit] : null);
let content: React.ReactNode;
if (children) {
content = state ? children(state) : null;
} else if (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="text-xl font-medium text-muted-foreground">
:
</span>
)}
<CountdownTimerUnit value={valueOf(unit)} label={resolvedLabels[unit]} className="text-xl font-semibold" />
</React.Fragment>
));
} else {
content = (
<span data-slot="countdown-timer-display" className="tabular-nums">
{units.map((unit, index) => (
<React.Fragment key={unit}>
{index > 0 ? <span aria-hidden>:</span> : null}
<CountdownTimerValue value={pad(valueOf(unit))} />
</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 };
Dependencies
shadcn registry
use-reduced-motion