Countdown timer component
A countdown element as split flap tiles, rings per unit, compact inline text or a bar to a deadline. Server and browser start from the same value, screen readers hear it once a minute.
Time left, shown the way it fits
A countdown shows how much time is left until something starts or ends. This one is an element, not a whole launch section, so it fits into a bar, a button, a code field or a card, with unit names and dates in the language of the page.
Split flap tiles work like a departure board, the old top half folds down and the new number lands every second. Rings fill a ring per unit with what is left of it, a ring that wraps from zero jumps back instead of running the long way round, and the last minutes turn to the warning color. Inline sits compact in a bar, in a button and under a code field, the code timer ends after thirty seconds and hands over to the resend button. Progress to deadline draws a bar from the start of a period to its deadline with dates in a fixed time zone, and an ended bar says so in words.
Give the countdown a fixed start value so the page renders the same on the server and in the browser. Screen readers hear the time once a minute, not every second. Decide what happens at zero and set it with onEnd, a new state beats a clock stuck at 00.
Variants
Details
What Countdown brings along, read from the registry and the source.
- Category
- Data display
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/countdown.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CountdownProps interface from components/ui/countdown.tsx, with the comments from the code.
variantoptionalCountdownVariantflipringsinlineprogress
targetoptionalstringThe end as an ISO date, e.g. 2026-11-28T18:00:00+01:00
durationoptionalnumberLength in seconds when there is no target, counted from the first render
nowoptionalstringThe moment the page is rendered, ISO. Server and browser start from this same value, so hydration stays clean.
startoptionalstringThe start of the period, ISO, for the progress bar
clockoptional'real' | 'relative'real: after hydration the visitor's clock takes over (live pages). relative: counts on from `now` (previews, screenshots, a server time you trust more).
unitsoptionalCountdownUnit[]labeloptionalstringA line above or before the time, e.g. "Sale ends in"
endTextoptionalstringShown once the time is up
sizeoptional'sm' | 'md' | 'lg'timeZoneoptionalstringTime zone for the dates of the progress bar. Fixed, so server and browser write the same text.
urgentBelowoptionalnumberSeconds left from which the time turns into the warning color
onEndoptional() => voidchildrenoptionalReact.ReactNodeFor inline: content after the time, e.g. inside a button
Built-in texts
Countdown writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| left | English {t} left | German noch {t} |
| and | English and | German und |
| ended | English Time is up | German Die Zeit ist um |
| start | English Start | German Beginn |
| deadline | English Deadline | German Frist |
| elapsed | English {p} of the time has passed | German {p} der Zeit sind vorbei |
Accessibility
Read from the source of the component.
- Sets aria-atomic, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles progressbar and timer.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 405 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
const TEXTS = {
en: { left: '{t} left', and: 'and', ended: 'Time is up', start: 'Start', deadline: 'Deadline', elapsed: '{p} of the time has passed' },
de: { left: 'noch {t}', and: 'und', ended: 'Die Zeit ist um', start: 'Beginn', deadline: 'Frist', elapsed: '{p} der Zeit sind vorbei' },
};
export type CountdownUnit = 'days' | 'hours' | 'minutes' | 'seconds';
/** flip: split flap tiles. rings: a ring per unit. inline: compact in a sentence or a button. progress: a bar from start to deadline. */
export type CountdownVariant = 'flip' | 'rings' | 'inline' | 'progress';
const UNIT_NAMES: Record<Locale, Record<CountdownUnit, [string, string, string]>> = {
// one, many, short
en: { days: ['day', 'days', 'd'], hours: ['hour', 'hours', 'h'], minutes: ['minute', 'minutes', 'm'], seconds: ['second', 'seconds', 's'] },
de: { days: ['Tag', 'Tage', 'T'], hours: ['Stunde', 'Stunden', 'Std.'], minutes: ['Minute', 'Minuten', 'Min.'], seconds: ['Sekunde', 'Sekunden', 'Sek.'] },
};
const UNIT_SECONDS: Record<CountdownUnit, number> = { days: 86400, hours: 3600, minutes: 60, seconds: 1 };
const ORDER: CountdownUnit[] = ['days', 'hours', 'minutes', 'seconds'];
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
/** Splits seconds into the given units, the largest one takes the rest (no days: hours run past 24) */
export function splitTime(total: number, units: CountdownUnit[]) {
let rest = Math.max(0, Math.floor(total));
const out = {} as Record<CountdownUnit, number>;
for (const u of ORDER.filter((x) => units.includes(x))) {
out[u] = Math.floor(rest / UNIT_SECONDS[u]);
rest -= out[u] * UNIT_SECONDS[u];
}
return out;
}
/** A spoken length of time, e.g. "2 days, 14 hours and 5 minutes" */
export function speakTime(total: number, units: CountdownUnit[], locale: Locale) {
const parts = splitTime(total, units);
const and = TEXTS[locale].and;
const words = ORDER.filter((u) => units.includes(u) && parts[u] > 0).map((u) => `${parts[u]} ${UNIT_NAMES[locale][u][parts[u] === 1 ? 0 : 1]}`);
if (words.length <= 1) return words[0] ?? `0 ${UNIT_NAMES[locale][units[units.length - 1] ?? 'seconds'][1]}`;
return `${words.slice(0, -1).join(', ')} ${and} ${words[words.length - 1]}`;
}
export interface CountdownProps {
variant?: CountdownVariant;
/** The end as an ISO date, e.g. 2026-11-28T18:00:00+01:00 */
target?: string;
/** Length in seconds when there is no target, counted from the first render */
duration?: number;
/** The moment the page is rendered, ISO. Server and browser start from this same value, so hydration stays clean. */
now?: string;
/** The start of the period, ISO, for the progress bar */
start?: string;
/**
* real: after hydration the visitor's clock takes over (live pages).
* relative: counts on from `now` (previews, screenshots, a server time you trust more).
*/
clock?: 'real' | 'relative';
units?: CountdownUnit[];
/** A line above or before the time, e.g. "Sale ends in" */
label?: string;
/** Shown once the time is up */
endText?: string;
size?: 'sm' | 'md' | 'lg';
/** Time zone for the dates of the progress bar. Fixed, so server and browser write the same text. */
timeZone?: string;
/** Seconds left from which the time turns into the warning color */
urgentBelow?: number;
onEnd?: () => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
/** For inline: content after the time, e.g. inside a button */
children?: React.ReactNode;
}
/** The current time in ms: fixed on the first render, then ticking */
function useClock(start: number, clock: 'real' | 'relative', running: boolean) {
const [now, setNow] = useState(start);
useEffect(() => {
if (!running) return;
const base = performance.now();
const read = () => (clock === 'real' ? Date.now() : start + (performance.now() - base));
// Whole seconds are what we show, so a quarter second is enough and costs nothing while the tab sleeps
const tick = () => setNow(Math.floor(read() / 250) * 250);
tick();
const id = window.setInterval(tick, 250);
const onVisible = () => document.visibilityState === 'visible' && tick();
document.addEventListener('visibilitychange', onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
}, [start, clock, running]);
return now;
}
/**
* A countdown element in four forms: split flap tiles, rings per unit, compact inline text and a bar
* from start to deadline. Server and browser render the same start value (`now`), then it ticks.
* role="timer", screen readers hear the time once a minute, not every second. Ends with `endText`.
* <Countdown variant="flip" target="2026-11-28T18:00:00Z" now={serverNow} label="Sale ends in" />
*/
export function Countdown({
variant = 'flip',
target,
duration,
now: nowProp,
start: startProp,
clock = 'real',
units = ['days', 'hours', 'minutes', 'seconds'],
label,
endText,
size = 'md',
timeZone = 'UTC',
urgentBelow,
onEnd,
labels,
className,
children,
}: CountdownProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const t0 = nowProp ? Date.parse(nowProp) : 0;
const end = target ? Date.parse(target) : t0 + (duration ?? 0) * 1000;
const hasStart = Boolean(nowProp) || !target;
const now = useClock(t0, nowProp ? clock : clock === 'real' && target ? 'real' : 'relative', true);
// Without a known start the first render shows the full duration, never a guess
const remaining = hasStart ? Math.max(0, Math.ceil((end - now) / 1000)) : Math.max(0, duration ?? 0);
const done = remaining <= 0;
const parts = splitTime(remaining, units);
const names = UNIT_NAMES[locale];
const [initialTotal] = useState(remaining);
const urgent = urgentBelow !== undefined && remaining <= urgentBelow && !done;
const ended = useRef(false);
useEffect(() => {
if (done && !ended.current) {
ended.current = true;
onEnd?.();
}
if (!done) ended.current = false;
}, [done, onEnd]);
// Spoken once a minute (every ten seconds in the last minute), the screen reader is not flooded
const spokenStep = remaining > 60 ? 60 : 10;
const spoken = done ? endText ?? t.ended : `${label ? `${label} ` : ''}${speakTime(Math.ceil(remaining / spokenStep) * spokenStep, units, locale)}`;
const timer = (
<span role="timer" aria-live="off" aria-atomic="true" className="sr-only">
{spoken}
</span>
);
const endAnnouncement = (
<span aria-live="polite" className="sr-only">
{done ? endText ?? t.ended : ''}
</span>
);
const pad = (n: number) => String(n).padStart(2, '0');
const shown = ORDER.filter((u) => units.includes(u));
/* ---------- inline ---------- */
if (variant === 'inline') {
const withDays = units.includes('days') && parts.days > 0;
// 4:05:09 or 0:30, the leading unit without a zero in front
const clockText = shown
.filter((u) => u !== 'days')
.map((u, i) => (i === 0 && !withDays ? String(parts[u]) : pad(parts[u])))
.join(':');
const days = withDays ? `${parts.days}${locale === 'de' ? ' T' : 'd'} ` : '';
return (
<span className={cn('inline-flex items-baseline gap-1.5', className)}>
{timer}
{endAnnouncement}
<span aria-hidden className="contents">
{done ? (
<span>{endText ?? t.ended}</span>
) : (
<>
{label && <span>{label}</span>}
<span className={cn('font-semibold tabular-nums transition-colors duration-500', urgent && 'text-[color-mix(in_oklch,var(--destructive)_80%,currentColor)]')}>
{days}
{clockText}
</span>
</>
)}
{children}
</span>
</span>
);
}
/* ---------- progress ---------- */
if (variant === 'progress') {
const s = startProp ? Date.parse(startProp) : end - (duration ?? 3600) * 1000;
const p = done ? 100 : hasStart ? Math.min(100, Math.max(0, ((now - s) / Math.max(1, end - s)) * 100)) : 0;
const date = (ms: number) => new Intl.DateTimeFormat(TAG[locale], { day: 'numeric', month: 'short', hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone }).format(ms);
return <ProgressCountdown {...{ className, label, timer, endAnnouncement, done, endText: endText ?? t.ended, p, urgent, size }} startText={`${t.start} ${date(s)}`} endDate={`${t.deadline} ${date(end)}`} leftText={t.left.replace('{t}', speakTime(remaining, shown.length > 1 ? shown.filter((u) => u !== 'seconds') : shown, locale))} percentText={t.elapsed.replace('{p}', formatPercent(Math.floor(p), locale))} id={id} />;
}
/* ---------- flip and rings ---------- */
const big = { sm: 'text-2xl', md: 'text-[28px] sm:text-[44px]', lg: 'text-[34px] sm:text-[64px]' }[size];
return (
<div className={cn('grid w-full min-w-0 justify-items-center gap-4', className)}>
{label && (
<p id={`${id}-l`} className="text-center text-sm font-medium text-muted-foreground">
{label}
</p>
)}
{timer}
{endAnnouncement}
{done ? (
<p aria-hidden className="kit-rise text-center text-lg font-semibold text-foreground">
{endText ?? t.ended}
</p>
) : (
<div aria-hidden className={cn('flex items-start justify-center', variant === 'rings' ? 'gap-2.5 sm:gap-5' : 'gap-1 sm:gap-3')}>
{shown.map((u, i) => {
const n = parts[u];
const name = n === 1 ? names[u][0] : names[u][1];
return (
<div key={u} className="flex items-start gap-1 sm:gap-3">
{variant === 'rings' ? (
<Ring value={n} max={u === 'days' ? Math.max(1, Math.ceil(initialTotal / 86400)) : u === 'hours' ? (units.includes('days') ? 24 : Math.max(24, n)) : 60} text={pad(n)} label={name} size={size} urgent={urgent} />
) : (
<div className="grid justify-items-center gap-2">
<FlipCard value={pad(n)} className={big} size={size} urgent={urgent} />
<span className="text-[10px] font-medium uppercase tracking-[0.06em] text-subtle-foreground sm:text-xs sm:tracking-[0.1em]">{name}</span>
</div>
)}
{variant === 'flip' && i < shown.length - 1 && (
<span className={cn('grid gap-1.5 self-center pb-6 sm:gap-2', size === 'sm' && 'gap-1')}>
<span className="size-1 rounded-full bg-border-strong sm:size-1.5" />
<span className="size-1 rounded-full bg-border-strong sm:size-1.5" />
</span>
)}
</div>
);
})}
</div>
)}
{children}
</div>
);
}
/* A split flap: the old top half folds down, the new bottom half lands on it */
const FLIP_CSS =
'@keyframes kitfx-flap-top{to{transform:rotateX(-90deg)}}@keyframes kitfx-flap-bottom{from{transform:rotateX(90deg)}to{transform:rotateX(0)}}' +
'.kitfx-flap-top{animation:kitfx-flap-top .28s cubic-bezier(.55,0,.8,.4) both;transform-origin:50% 100%;backface-visibility:hidden}' +
'.kitfx-flap-bottom{animation:kitfx-flap-bottom .3s cubic-bezier(.2,.7,.3,1.2) .28s both;transform-origin:50% 0;backface-visibility:hidden}' +
'@media (prefers-reduced-motion:reduce){.kitfx-flap-top,.kitfx-flap-bottom{display:none}}';
// Four tiles and their labels fit a 390 px phone, they grow from 640 px on
const FLIP_SIZE = { sm: 'h-14 w-12 rounded-lg', md: 'h-16 w-14 rounded-lg sm:h-24 sm:w-20 sm:rounded-xl', lg: 'h-[72px] w-[60px] rounded-xl sm:h-32 sm:w-28' };
function FlipCard({ value, className, size, urgent }: { value: string; className: string; size: 'sm' | 'md' | 'lg'; urgent: boolean }) {
const [prev, setPrev] = useState(value);
const [flipKey, setFlipKey] = useState(0);
const last = useRef(value);
useEffect(() => {
if (value === last.current) return;
setPrev(last.current);
last.current = value;
if (prefersReducedMotion()) setPrev(value);
else setFlipKey((k) => k + 1);
}, [value]);
const flipping = prev !== value;
const half = (text: string, part: 'top' | 'bottom', extra?: string) => (
<span className={cn('absolute inset-x-0 h-1/2 overflow-hidden', part === 'top' ? 'top-0 rounded-t-[inherit]' : 'bottom-0 rounded-b-[inherit]', 'bg-card', extra)}>
<span className={cn('absolute inset-x-0 flex h-[200%] items-center justify-center', part === 'top' ? 'top-0' : 'bottom-0')}>{text}</span>
</span>
);
return (
<span
className={cn(
'relative block font-semibold tabular-nums leading-none tracking-[-0.03em] text-foreground shadow-[var(--highlight),var(--shadow-md)] [perspective:400px]',
'border border-border bg-card transition-colors duration-500',
urgent && 'text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]',
FLIP_SIZE[size],
className,
)}
>
<style href="kitfx-flap" precedence="default">
{FLIP_CSS}
</style>
{half(value, 'top')}
{half(prev, 'bottom')}
{flipping && (
<span key={flipKey} className="contents" onAnimationEnd={(e) => e.animationName === 'kitfx-flap-bottom' && setPrev(value)}>
{half(prev, 'top', 'kitfx-flap-top z-10')}
{half(value, 'bottom', 'kitfx-flap-bottom z-10')}
</span>
)}
{/* The hinge */}
<span className="absolute inset-x-0 top-1/2 z-20 h-px -translate-y-px bg-[color-mix(in_oklab,var(--background)_85%,transparent)]" />
</span>
);
}
const RING_SIZE = { sm: 64, md: 76, lg: 112 };
function Ring({ value, max, text, label, size, urgent }: { value: number; max: number; text: string; label: string; size: 'sm' | 'md' | 'lg'; urgent: boolean }) {
const px = RING_SIZE[size];
const r = 44;
const c = round(2 * Math.PI * r, 3);
const [ref, inView] = useInView<HTMLDivElement>();
const frac = inView ? Math.min(1, value / max) : 0;
// A unit that wraps around (59 back to 0 and up to 59) jumps, it does not run back the long way
const prev = useRef(value);
const wrapped = value > prev.current;
useEffect(() => {
prev.current = value;
}, [value]);
return (
<div ref={ref} className="grid justify-items-center gap-2">
<div className="relative grid place-items-center max-sm:![width:var(--s)] max-sm:![height:var(--s)]" style={{ width: px, height: px, '--s': `${Math.round(px * 0.82)}px` } as React.CSSProperties}>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full -rotate-90">
<circle cx="50" cy="50" r={r} fill="none" strokeWidth="5" className="stroke-track" />
<circle
cx="50"
cy="50"
r={r}
fill="none"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={round(c * (1 - frac), 3)}
className={cn('stroke-primary', urgent && 'stroke-destructive', !wrapped && 'transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none')}
/>
</svg>
<span className={cn('relative font-semibold tabular-nums tracking-[-0.02em] text-foreground', size === 'lg' ? 'text-[28px] sm:text-[34px]' : size === 'sm' ? 'text-lg' : 'text-xl sm:text-2xl')}>{text}</span>
</div>
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-subtle-foreground sm:text-xs">{label}</span>
</div>
);
}
function ProgressCountdown({
id,
className,
label,
timer,
endAnnouncement,
done,
endText,
p,
urgent,
size,
startText,
endDate,
leftText,
percentText,
}: {
id: string;
className?: string;
label?: string;
timer: React.ReactNode;
endAnnouncement: React.ReactNode;
done: boolean;
endText: string;
p: number;
urgent: boolean;
size: 'sm' | 'md' | 'lg';
startText: string;
endDate: string;
leftText: string;
percentText: string;
}) {
const [ref, inView] = useInView<HTMLDivElement>();
return (
<div ref={ref} className={cn('grid w-full min-w-0 gap-2.5', className)}>
{timer}
{endAnnouncement}
<div aria-hidden className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
{label && <span className="text-sm font-medium text-foreground">{label}</span>}
<span className={cn('ms-auto font-semibold tabular-nums transition-colors duration-500', size === 'lg' ? 'text-lg' : 'text-sm', done ? 'text-success-foreground' : urgent ? 'text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]' : 'text-foreground')}>
{done ? endText : leftText}
</span>
</div>
<div
role="progressbar"
aria-label={label ?? percentText}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(p)}
aria-valuetext={percentText}
className={cn('relative overflow-hidden rounded-full bg-track', size === 'sm' ? 'h-1.5' : size === 'lg' ? 'h-3' : 'h-2')}
>
<div
id={`${id}-bar`}
className={cn(
'absolute inset-y-0 start-0 rounded-full transition-[width,background-color] duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none',
done ? 'bg-success' : urgent ? 'bg-destructive' : 'bg-primary',
)}
style={{ width: `${round(inView ? p : 0, 2)}%` }}
/>
</div>
<div aria-hidden className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-[12.5px] text-muted-foreground">
<span>{startText}</span>
<span>{endDate}</span>
</div>
</div>
);
}Frequent questions
How do I install Countdown?
With aiw add @ai-website/countdown. The command puts components/ui/countdown.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Countdown have?
4 variants, Split flap tiles, Rings, Inline and Progress to deadline. You pick one with the variant prop, for example variant="rings".
Can I translate the texts of Countdown?
Yes. Countdown carries 6 texts of its own in English and German, such as “and”, “Time is up” and “Start”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Countdown?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Countdown with aiw_components and install it with aiw_add @ai-website/countdown. Name the variant in your prompt, for example variant="flip", and the assistant writes your content straight into the props.