Countdown Timer
Ein Countdown-Element als Klappkacheln, Ringe je Einheit, kompakter Text im Satz oder Balken bis zur Frist. Server und Browser starten vom selben Wert, Screenreader hören ihn einmal pro Minute.
Restzeit, so gezeigt, wie sie passt
Ein Countdown zeigt, wie viel Zeit bleibt, bis etwas beginnt oder endet. Dieser ist ein Element, keine ganze Launch-Section, er passt also in eine Leiste, einen Knopf, ein Codefeld oder eine Karte, mit Einheiten und Daten in der Sprache der Seite.
„Klappkacheln“ arbeiten wie eine Abfahrtstafel, die alte obere Hälfte klappt herunter und die neue Zahl landet jede Sekunde. „Ringe“ zeigen je Einheit, was von ihr übrig ist, ein Ring, der bei null umschlägt, springt zurück und läuft nie den langen Weg, in den letzten Minuten wechselt er in die Warnfarbe. „Im Text“ sitzt kompakt in einer Leiste, in einem Knopf und unter einem Codefeld, der Code-Timer endet nach dreißig Sekunden und übergibt an den Knopf zum erneuten Senden. „Fortschritt bis Frist“ zeichnet einen Balken vom Beginn eines Zeitraums bis zur Frist mit fester Zeitzone, ein abgelaufener Balken sagt es in Worten.
Gib dem Countdown einen festen Startwert, dann rendert die Seite auf dem Server und im Browser gleich. Screenreader hören die Zeit einmal pro Minute, nicht jede Sekunde. Leg mit onEnd fest, was bei null passiert, ein neuer Zustand wirkt besser als eine Uhr, die bei 00 stehen bleibt.
Variants
Details
Was Countdown mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Data Display
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/countdown.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 6 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CountdownProps aus components/ui/countdown.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Countdown selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 6 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| left | Englisch {t} left | Deutsch noch {t} |
| and | Englisch and | Deutsch und |
| ended | Englisch Time is up | Deutsch Die Zeit ist um |
| start | Englisch Start | Deutsch Beginn |
| deadline | Englisch Deadline | Deutsch Frist |
| elapsed | Englisch {p} of the time has passed | Deutsch {p} der Zeit sind vorbei |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-atomic, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen progressbar und timer.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 405 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Countdown?
Mit aiw add @ai-website/countdown. Der Befehl legt die Datei components/ui/countdown.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Countdown?
4 Varianten, Klappkacheln, Ringe, Im Text und Fortschritt bis Frist. Du wählst sie mit der Prop variant, zum Beispiel variant="rings".
Kann ich die Texte von Countdown übersetzen?
Ja. Countdown bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „und“, „Die Zeit ist um“ und „Beginn“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Countdown einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Countdown mit aiw_components und installieren sie mit aiw_add @ai-website/countdown. Nenn im Prompt die Variante, etwa variant="flip", dann schreibt der Assistent deine Inhalte direkt in die Props.