Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 9 Components in Data Display

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

KlappkachelnNeu in 1.2

Kacheln wie an einer Abfahrtstafel. Die alte obere Hälfte klappt herunter und die neue Zahl landet, jede Sekunde. Bei reduzierter Bewegung wechseln die Zahlen ruhig.

RingeNeu in 1.2

Ein Ring je Einheit zeigt, 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 TextNeu in 1.2

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, die nächste Runde startet neu.

Fortschritt bis FristNeu in 1.2

Ein Balken vom Beginn eines Zeitraums bis zur Frist, Daten im Format der Sprache mit fester Zeitzone. Der zweite Balken ist abgelaufen und sagt es in Worten.

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

Props

Die Schnittstelle CountdownProps aus components/ui/countdown.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CountdownVariant

    flipringsinlineprogress

  • targetoptional
    string

    The end as an ISO date, e.g. 2026-11-28T18:00:00+01:00

  • durationoptional
    number

    Length in seconds when there is no target, counted from the first render

  • nowoptional
    string

    The moment the page is rendered, ISO. Server and browser start from this same value, so hydration stays clean.

  • startoptional
    string

    The 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).

  • unitsoptional
    CountdownUnit[]
  • labeloptional
    string

    A line above or before the time, e.g. "Sale ends in"

  • endTextoptional
    string

    Shown once the time is up

  • sizeoptional
    'sm' | 'md' | 'lg'
  • timeZoneoptional
    string

    Time zone for the dates of the progress bar. Fixed, so server and browser write the same text.

  • urgentBelowoptional
    number

    Seconds left from which the time turns into the warning color

  • onEndoptional
    () => void
  • childrenoptional
    React.ReactNode

    For 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
leftEnglisch {t} leftDeutsch noch {t}
andEnglisch andDeutsch und
endedEnglisch Time is upDeutsch Die Zeit ist um
startEnglisch StartDeutsch Beginn
deadlineEnglisch DeadlineDeutsch Frist
elapsedEnglisch {p} of the time has passedDeutsch {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
components/ui/countdown.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert