Launch-Countdown
Der Moment vor dem Start mit einer Uhr, die rückwärts zählt: Fallblattziffern auf dunkler Bühne, Ringe, die sich leeren, eine Woche verschlossener Enthüllungen und eine Ticketkarte mit Plätzen und Erinnerung.
Der Moment vor dem Launch
Ein Launch-Countdown macht aus einem Datum Vorfreude und sammelt die Menschen, die beim Start dabei sein wollen. Die vier Sections hier verbinden die Uhr mit einem Weg, in Kontakt zu bleiben, einem E-Mail-Feld, einer Erinnerung oder einem Platz.
Die „Fallblatt-Anzeige“ zeigt Fallblattziffern auf dunkler Bühne, jede neue Ziffer klappt vom Scharnier herab, darunter ein E-Mail-Feld mit Fehler- und Erfolgszustand und ein Link in den Kalender. „Ringe“ nutzt einen großen Ring für die Tage und drei kleine für Stunden, Minuten und Sekunden, jeder leert sich mit der Zeit, ein Glockenknopf schaltet die Erinnerung ein und aus. Die „Woche der Enthüllungen“ zeigt Karten, geöffnete Tage führen zur Meldung, verschlossene halten den Titel unscharf und zählen herunter, die nächste leuchtet. Die „Ticketkarte“ ist eine kompakte Karte in Ticketform mit Perforation, die Uhr in vier Kacheln, freie Plätze auf einem Balken und ein Erinnerungsknopf.
Verbinde jeden Countdown mit einer Aktion, eintragen, erinnern oder Platz buchen. Nenn Datum und Zeitzone genau neben der Uhr. Wenn der Countdown endet, schalte die Section auf den Launch selbst um, eine Uhr bei null hilft niemandem.
Variants
Details
Was Launch-Countdown mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Launch und Waitlist
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/countdown-launch.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 25 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Event
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CountdownLaunchProps aus components/ui/countdown-launch.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCountdownLaunchVariantflip-boardringsunlock-weekticket
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNoderemainingoptionalnumberSeconds left, counted from when the page shows
endsAtoptionalstringOr a fixed moment, e.g. 2026-11-20T18:00:00+01:00
spanDaysoptionalnumberrings: the full span in days the day ring stands for (default 30)
whenoptionalstringflip-board, rings: when it starts, as text, e.g. Thursday, 20 November at 6 PM
calendaroptional{ label: string; href: string }A link to an .ics file or a calendar page
revealsoptionalLaunchReveal[]unlock-week
seatsoptional{ left: number; total: number }ticket
liveoptional{ label: string; href: string }Shown once the clock reaches zero
onNotifyoptional(email: string) => void | Promise<void>Resolve to show success, throw to show the error
formActionoptionalstringnoteoptionalstring
Eingebaute Texte
Diese Texte schreibt Launch-Countdown selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 25 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| days | Englisch Days | Deutsch Tage |
| day1 | Englisch day | Deutsch Tag |
| daysWord | Englisch days | Deutsch Tage |
| hoursWord | Englisch hours | Deutsch Stunden |
| hour1 | Englisch hour | Deutsch Stunde |
| hours | Englisch Hours | Deutsch Stunden |
| minutes | Englisch Minutes | Deutsch Minuten |
| seconds | Englisch Seconds | Deutsch Sekunden |
| left | Englisch {d} days, {h} hours, {m} minutes left | Deutsch Noch {d} Tage, {h} Stunden, {m} Minuten |
| Englisch Email address | Deutsch E-Mail-Adresse | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| notify | Englisch Tell me when it starts | Deutsch Zum Start benachrichtigen |
| sending | Englisch Saving | Deutsch Wird gespeichert |
| invalid | Englisch Please enter a full email address, like name@example.com. | Deutsch Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@beispiel.de. |
| failed | Englisch That did not go through. Please try again in a moment. | Deutsch Das hat nicht geklappt. Bitte versuch es gleich noch einmal. |
| done | Englisch Done. We will write to you the moment it starts. | Deutsch Erledigt. Wir schreiben dir, sobald es losgeht. |
| remind | Englisch Remind me | Deutsch Erinnern |
| reminded | Englisch Reminder on | Deutsch Erinnerung an |
| remindOff | Englisch Turn the reminder off | Deutsch Erinnerung ausschalten |
| liveNow | Englisch Live now | Deutsch Jetzt live |
| opensIn | Englisch Opens in {t} | Deutsch Öffnet in {t} |
| open | Englisch Open | Deutsch Offen |
| nextOpen | Englisch Next reveal in | Deutsch Nächste Enthüllung in |
| seatsLeft | Englisch {n} of {t} seats left | Deutsch Noch {n} von {t} Plätzen |
| locked | Englisch Locked until its day | Deutsch Bis zu seinem Tag verschlossen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-invalid, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, status und timer.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- 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. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 584 Zeilen
'use client';
import { useEffect, useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Eyebrow } from '@/components/ui/section';
/*
* The moment before a launch, with a clock that counts down and a way to be
* told when it starts. Four layouts:
*
* flip-board split-flap digits on a dark stage, an email field and a calendar link
* rings four rings that empty with the time, a bell that turns on a reminder
* unlock-week a week of reveals, locked days blurred until their hour comes
* ticket a compact card with the clock, seats left and a reminder, for a side column
*
* The time comes from `remaining` (seconds, counted from the moment the page
* shows) or `endsAt` (an ISO date). With `endsAt` the server renders dashes
* and the browser fills in the real time, so both always agree.
*/
export type CountdownLaunchVariant = 'flip-board' | 'rings' | 'unlock-week' | 'ticket';
export interface LaunchReveal {
day: string;
title: string;
text?: string;
/** Seconds until it opens, 0 or less is open */
opensIn: number;
href?: string;
}
export interface CountdownLaunchProps {
variant?: CountdownLaunchVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** Seconds left, counted from when the page shows */
remaining?: number;
/** Or a fixed moment, e.g. 2026-11-20T18:00:00+01:00 */
endsAt?: string;
/** rings: the full span in days the day ring stands for (default 30) */
spanDays?: number;
/** flip-board, rings: when it starts, as text, e.g. Thursday, 20 November at 6 PM */
when?: string;
/** A link to an .ics file or a calendar page */
calendar?: { label: string; href: string };
/** unlock-week */
reveals?: LaunchReveal[];
/** ticket */
seats?: { left: number; total: number };
/** Shown once the clock reaches zero */
live?: { label: string; href: string };
/** Resolve to show success, throw to show the error */
onNotify?: (email: string) => void | Promise<void>;
formAction?: string;
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
days: 'Days',
day1: 'day',
daysWord: 'days',
hoursWord: 'hours',
hour1: 'hour',
hours: 'Hours',
minutes: 'Minutes',
seconds: 'Seconds',
left: '{d} days, {h} hours, {m} minutes left',
email: 'Email address',
emailPh: 'you@example.com',
notify: 'Tell me when it starts',
sending: 'Saving',
invalid: 'Please enter a full email address, like name@example.com.',
failed: 'That did not go through. Please try again in a moment.',
done: 'Done. We will write to you the moment it starts.',
remind: 'Remind me',
reminded: 'Reminder on',
remindOff: 'Turn the reminder off',
liveNow: 'Live now',
opensIn: 'Opens in {t}',
open: 'Open',
nextOpen: 'Next reveal in',
seatsLeft: '{n} of {t} seats left',
locked: 'Locked until its day',
},
de: {
days: 'Tage',
day1: 'Tag',
daysWord: 'Tage',
hoursWord: 'Stunden',
hour1: 'Stunde',
hours: 'Stunden',
minutes: 'Minuten',
seconds: 'Sekunden',
left: 'Noch {d} Tage, {h} Stunden, {m} Minuten',
email: 'E-Mail-Adresse',
emailPh: 'du@beispiel.de',
notify: 'Zum Start benachrichtigen',
sending: 'Wird gespeichert',
invalid: 'Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@beispiel.de.',
failed: 'Das hat nicht geklappt. Bitte versuch es gleich noch einmal.',
done: 'Erledigt. Wir schreiben dir, sobald es losgeht.',
remind: 'Erinnern',
reminded: 'Erinnerung an',
remindOff: 'Erinnerung ausschalten',
liveNow: 'Jetzt live',
opensIn: 'Öffnet in {t}',
open: 'Offen',
nextOpen: 'Nächste Enthüllung in',
seatsLeft: 'Noch {n} von {t} Plätzen',
locked: 'Bis zu seinem Tag verschlossen',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = CountdownLaunchProps & { t: Texts; left: number | null };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const pad = (n: number) => String(n).padStart(2, '0');
const split = (s: number) => ({ d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 });
/** Seconds left. With endsAt it is null on the server and until the first tick. */
function useLeft(remaining?: number, endsAt?: string) {
const end = endsAt ? new Date(endsAt).getTime() : Number.NaN;
const [left, setLeft] = useState<number | null>(endsAt ? null : Math.max(0, Math.floor(remaining ?? 0)));
useEffect(() => {
const t0 = Date.now();
const tick = () => {
if (endsAt && Number.isFinite(end)) setLeft(Math.max(0, Math.floor((end - Date.now()) / 1000)));
else setLeft(Math.max(0, Math.floor(remaining ?? 0) - Math.floor((Date.now() - t0) / 1000)));
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [remaining, endsAt, end]);
return left;
}
function Head({ eyebrow, title, description, center, className, dark }: Pick<CountdownLaunchProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string; dark?: boolean }) {
return (
<div className={cn('max-w-[44rem]', center && 'mx-auto text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className={cn('kit-rise text-balance font-semibold text-foreground', dark ? 'text-h1' : 'text-h2')}>{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
function NotifyForm({ t, onNotify, formAction, center }: { t: Texts; onNotify?: CountdownLaunchProps['onNotify']; formAction?: string; center?: boolean }) {
const id = useId();
const [state, setState] = useState<'idle' | 'invalid' | 'sending' | 'failed' | 'done'>('idle');
if (state === 'done')
return (
<p role="status" className={cn('kit-rise flex min-h-12 max-w-[30rem] items-center gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] px-4 py-3 text-[14.5px] text-foreground', center && 'mx-auto')}>
<Icon name="bell" size={18} className="shrink-0 text-success" />
{t.done}
</p>
);
const bad = state === 'invalid';
return (
<form
action={formAction}
method="post"
noValidate
className={cn('w-full max-w-[30rem]', center && 'mx-auto')}
onSubmit={async (e) => {
const email = String(new FormData(e.currentTarget).get('email') ?? '').trim();
if (!EMAIL.test(email)) {
e.preventDefault();
setState('invalid');
e.currentTarget.querySelector('input')?.focus();
return;
}
if (!onNotify && formAction) return;
e.preventDefault();
setState('sending');
try {
await (onNotify ? onNotify(email) : new Promise((r) => setTimeout(r, 700)));
setState('done');
} catch {
setState('failed');
}
}}
>
<label htmlFor={id} className="sr-only">
{t.email}
</label>
<div className={cn('flex flex-col gap-2 sm:flex-row sm:rounded-xl sm:border sm:bg-card sm:p-1.5 sm:shadow-[var(--highlight),var(--shadow-md)] sm:focus-within:shadow-[0_0_0_4px_var(--glow),var(--shadow-md)]', bad ? 'sm:border-destructive' : 'sm:border-border')}>
<input
id={id}
name="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder={t.emailPh}
aria-invalid={bad || undefined}
aria-describedby={bad || state === 'failed' ? `${id}-e` : undefined}
onInput={() => bad && setState('idle')}
className={cn('h-12 min-w-0 rounded-lg border bg-card px-4 text-base text-foreground placeholder:text-subtle-foreground focus-visible:outline-none max-sm:focus-visible:shadow-[0_0_0_4px_var(--glow)] sm:h-11 sm:flex-1 sm:border-0 sm:bg-transparent sm:text-[15px]', bad ? 'border-destructive' : 'border-border')}
/>
<Button type="submit" size="lg" disabled={state === 'sending'} iconLeft={state === 'sending' ? undefined : 'bell'} className="max-sm:w-full sm:h-11">
{state === 'sending' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
{state === 'sending' ? t.sending : t.notify}
</Button>
</div>
{(bad || state === 'failed') && (
<p id={`${id}-e`} role="alert" className={cn('mt-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive', center && 'justify-center')}>
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{bad ? t.invalid : t.failed}
</p>
)}
</form>
);
}
function LiveBadge({ live, t }: { live?: CountdownLaunchProps['live']; t: Texts }) {
const content = (
<>
<span className="relative flex size-2.5">
<span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
<span className="relative size-2.5 rounded-full bg-success" />
</span>
{live?.label ?? t.liveNow}
{live && <Icon name="arrow-right" size={16} />}
</>
);
const cls = 'kit-rise inline-flex min-h-12 items-center gap-3 rounded-full border border-border bg-card px-5 text-[16px] font-semibold text-foreground shadow-[var(--shadow-md)]';
return live ? (
<a href={live.href} className={cn(cls, 'cursor-pointer hover:border-border-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary')}>
{content}
</a>
) : (
<p className={cls}>{content}</p>
);
}
/* ------------------------------------------------------------------ */
/* flip-board */
/* ------------------------------------------------------------------ */
/** One split-flap card: the new digit drops in from the top half */
function Flap({ ch }: { ch: string }) {
return (
<span className="relative inline-grid h-14 w-[2.1rem] overflow-hidden rounded-md sm:rounded-lg bg-[color-mix(in_oklab,var(--foreground)_9%,var(--background))] text-[2.1rem] font-semibold tabular-nums leading-none tracking-[-0.04em] text-foreground shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_10%,transparent),var(--shadow-md)] [perspective:300px] sm:h-24 sm:w-[3.6rem] sm:text-[3.75rem] lg:h-28 lg:w-[4.2rem] lg:text-[4.5rem]">
<span key={ch} className="grid place-items-center [grid-area:1/1] animate-[cl-flip_.55s_var(--ease-kit)] [transform-origin:50%_0] motion-reduce:animate-none">
{ch}
</span>
{/* the hinge across the middle */}
<span aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-[color-mix(in_oklab,var(--background)_70%,transparent)]" />
<span aria-hidden className="absolute inset-x-0 top-0 h-1/2 bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--foreground)_5%,transparent),transparent)]" />
</span>
);
}
function FlipBoard(p: ViewProps) {
const { t, left } = p;
const parts = left === null ? null : split(left);
const groups: [string, string][] = [
[parts ? pad(Math.min(99, parts.d)) : '--', t.days],
[parts ? pad(parts.h) : '--', t.hours],
[parts ? pad(parts.m) : '--', t.minutes],
[parts ? pad(parts.s) : '--', t.seconds],
];
return (
<section className={cn('dark relative overflow-hidden bg-background py-24 text-foreground sm:py-32', p.className)}>
<div aria-hidden className="kit-dots pointer-events-none absolute inset-0 opacity-60" />
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-[70%] bg-[radial-gradient(50%_60%_at_50%_0%,var(--glow),transparent_70%)]" />
<div className="kit-container relative flex flex-col items-center text-center">
<Head {...p} center dark />
{p.when && (
<p className="kit-rise mt-6 inline-flex items-center gap-2 text-[14px] text-muted-foreground" style={delay(120)}>
<Icon name="calendar" size={16} />
{p.when}
</p>
)}
<div className="mt-12">
{left === 0 ? (
<LiveBadge live={p.live} t={t} />
) : (
<div role="timer" aria-label={parts ? fill(t.left, { d: parts.d, h: parts.h, m: parts.m }) : undefined} className="flex items-start justify-center gap-3.5 sm:gap-4">
<style>{'@keyframes cl-flip{from{transform:rotateX(-90deg);opacity:.2}}'}</style>
{groups.map(([digits, label], i) => (
<div key={label} className="kit-rise flex items-start gap-2 sm:gap-4" style={delay(160 + i * 80)}>
<div className="flex flex-col items-center">
<span aria-hidden className="flex gap-0.5 sm:gap-1">
{[...digits].map((c, j) => (
<Flap key={j} ch={c} />
))}
</span>
<span aria-hidden className="mt-3 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground sm:text-[12px]">{label}</span>
</div>
{i < 3 && <span aria-hidden className="pt-5 text-[1.5rem] font-semibold text-subtle-foreground max-sm:hidden sm:pt-8 sm:text-[2rem] lg:pt-10">:</span>}
</div>
))}
</div>
)}
</div>
<div className="kit-rise mt-12 flex w-full flex-col items-center gap-4" style={delay(400)}>
<NotifyForm t={t} onNotify={p.onNotify} formAction={p.formAction} center />
{p.calendar && (
<a href={p.calendar.href} className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[14px] font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="calendar-check" size={16} />
{p.calendar.label}
</a>
)}
{p.note && <p className="text-[13px] text-subtle-foreground">{p.note}</p>}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* rings */
/* ------------------------------------------------------------------ */
function Ring({ value, max, label, big }: { value: number | null; max: number; label: string; big?: boolean }) {
const r = 44;
const C = 2 * Math.PI * r;
const share = value === null ? 1 : value / max;
return (
<div className="flex flex-col items-center">
<span className={cn('relative grid place-items-center', big ? 'size-32 sm:size-40' : 'size-[4.75rem] sm:size-28')}>
<svg viewBox="0 0 100 100" aria-hidden className="absolute inset-0 size-full -rotate-90">
<circle cx="50" cy="50" r={r} fill="none" className="stroke-track" strokeWidth={big ? 5 : 6} />
<circle cx="50" cy="50" r={r} fill="none" className="stroke-primary transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" strokeWidth={big ? 5 : 6} strokeLinecap="round" strokeDasharray={round(C)} strokeDashoffset={round(C * (1 - share))} />
</svg>
<span className={cn('relative font-semibold tabular-nums tracking-[-0.04em] text-foreground', big ? 'text-[2.75rem] sm:text-[3.5rem]' : 'text-[1.5rem] sm:text-[2.25rem]')}>{value === null ? '--' : pad(value)}</span>
</span>
<span className="mt-3 text-[11.5px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{label}</span>
</div>
);
}
function Rings(p: ViewProps) {
const { t, left } = p;
const parts = left === null ? null : split(left);
const [on, setOn] = useState(false);
return (
<section className={cn('kit-glow relative overflow-hidden py-24 sm:py-32', p.className)}>
<div className="kit-container grid grid-cols-[minmax(0,1fr)] items-center gap-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-16">
<div>
<Head {...p} />
{p.when && (
<p className="kit-rise mt-6 flex items-center gap-2 text-[15px] font-medium text-foreground" style={delay(120)}>
<Icon name="calendar" size={17} className="text-primary" />
{p.when}
</p>
)}
<div className="kit-rise mt-9 flex flex-col gap-3 sm:flex-row sm:items-center" style={delay(180)}>
<Button
size="lg"
variant={on ? 'outline' : 'primary'}
iconLeft={on ? 'check' : 'bell'}
aria-pressed={on}
onClick={() => setOn(!on)}
className="max-sm:w-full"
>
{on ? t.reminded : t.remind}
</Button>
{p.calendar && (
<Button href={p.calendar.href} size="lg" variant="ghost" iconLeft="calendar-check" className="max-sm:w-full">
{p.calendar.label}
</Button>
)}
</div>
{p.note && <p className="mt-4 text-[13px] text-muted-foreground">{p.note}</p>}
</div>
<div className="kit-rise" style={delay(160)}>
{left === 0 ? (
<div className="flex justify-center">
<LiveBadge live={p.live} t={t} />
</div>
) : (
<div role="timer" aria-label={parts ? fill(t.left, { d: parts.d, h: parts.h, m: parts.m }) : undefined} className="flex flex-col items-center gap-8 rounded-3xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] max-lg:mx-auto max-lg:w-full max-lg:max-w-[34rem] sm:p-10">
<div aria-hidden>
<Ring big value={parts ? parts.d : null} max={p.spanDays ?? 30} label={t.days} />
</div>
<div aria-hidden className="grid w-full grid-cols-3 gap-2 border-t border-border pt-8 sm:gap-6">
<Ring value={parts ? parts.h : null} max={24} label={t.hours} />
<Ring value={parts ? parts.m : null} max={60} label={t.minutes} />
<Ring value={parts ? parts.s : null} max={60} label={t.seconds} />
</div>
</div>
)}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* unlock-week */
/* ------------------------------------------------------------------ */
function short(s: number, t: Texts) {
const x = split(s);
if (x.d > 0) return `${x.d} ${x.d === 1 ? t.day1 : t.daysWord} ${x.h} ${x.h === 1 ? t.hour1 : t.hoursWord}`;
return `${pad(x.h)}:${pad(x.m)}:${pad(x.s)}`;
}
function UnlockWeek(p: ViewProps) {
const { t } = p;
const reveals = p.reveals ?? [];
const [elapsed, setElapsed] = useState(0);
const [ref, inView] = useInView<HTMLDivElement>();
useEffect(() => {
const t0 = Date.now();
const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000);
return () => window.clearInterval(id);
}, []);
const nextIn = reveals.map((r) => r.opensIn - elapsed).filter((s) => s > 0).sort((a, b) => a - b)[0];
return (
<section className={cn('relative py-20 sm:py-24 lg:py-32', p.className)}>
<div className="kit-container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<Head {...p} />
{nextIn !== undefined && (
<div className="kit-rise flex items-center gap-3 rounded-2xl border border-border bg-card px-5 py-4 shadow-[var(--highlight),var(--shadow-sm)] lg:min-w-[18rem]" style={delay(120)}>
<Icon name="hourglass" size={20} className="shrink-0 text-primary" />
<div>
<p className="text-[12.5px] text-muted-foreground">{t.nextOpen}</p>
<p className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{short(nextIn, t)}</p>
</div>
</div>
)}
</div>
<div ref={ref} className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 sm:[&>:last-child:nth-child(odd)]:col-span-2 lg:grid-cols-[repeat(var(--n),minmax(0,1fr))] lg:[&>:last-child:nth-child(odd)]:col-span-1" style={{ ['--n' as string]: reveals.length }}>
{reveals.map((r, i) => {
const left = r.opensIn - elapsed;
const open = left <= 0;
const isNext = !open && left === nextIn;
const Tag = open && r.href ? 'a' : 'div';
return (
<Tag
key={i}
{...(Tag === 'a' ? { href: r.href } : {})}
className={cn(
'group/rv relative flex min-h-[8.5rem] flex-col sm:min-h-[13rem] overflow-hidden rounded-2xl border p-5 transition-[transform,box-shadow,opacity,border-color] duration-500 ease-[var(--ease-kit)]',
open ? 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'border-dashed border-border-strong bg-surface',
isNext && 'border-solid border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] shadow-[0_0_0_4px_var(--glow)]',
Tag === 'a' && 'cursor-pointer hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:hover:translate-y-0',
inView ? 'opacity-100' : 'translate-y-3 opacity-0',
)}
style={{ transitionDelay: inView ? `${i * 70}ms` : '0ms' }}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{r.day}</span>
<span className={cn('grid size-8 place-items-center rounded-full', open ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground ring-1 ring-inset ring-border')}>
<Icon name={open ? 'unlock' : 'lock'} size={15} />
</span>
</div>
<div className="mt-auto pt-6">
{open ? (
<>
<p className="text-pretty text-[17px] font-semibold leading-snug text-foreground">{r.title}</p>
{r.text && <p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{r.text}</p>}
{Tag === 'a' && <Icon name="arrow-right" size={16} className="mt-3 text-foreground transition-transform duration-300 group-hover/rv:translate-x-1" />}
</>
) : (
<>
<p aria-hidden className="select-none text-[17px] font-semibold leading-snug text-foreground blur-[6px]">{r.title}</p>
<p className="sr-only">{t.locked}</p>
<p className={cn('mt-3 inline-flex items-center gap-1.5 text-[13px] tabular-nums', isNext ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
<Icon name="clock" size={14} />
{fill(t.opensIn, { t: short(left, t) })}
</p>
</>
)}
</div>
</Tag>
);
})}
</div>
{p.note && <p className="mt-6 text-[13px] text-muted-foreground">{p.note}</p>}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* ticket */
/* ------------------------------------------------------------------ */
function Ticket(p: ViewProps) {
const { t, left } = p;
const locale = useLocale();
const parts = left === null ? null : split(left);
const [on, setOn] = useState(false);
const seats = p.seats;
const used = seats ? round(((seats.total - seats.left) / Math.max(1, seats.total)) * 100, 1) : 0;
const fmt = (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
const cells: [number | null, string][] = [
[parts ? parts.d : null, t.days],
[parts ? parts.h : null, t.hours],
[parts ? parts.m : null, t.minutes],
[parts ? parts.s : null, t.seconds],
];
return (
<div className={cn('relative w-full max-w-[26rem] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]', p.className)}>
<div className="p-6">
{p.eyebrow && <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{p.eyebrow}</p>}
<h3 className="mt-1 text-balance text-[20px] font-semibold leading-snug text-foreground">{p.title}</h3>
{p.when && <p className="mt-1.5 text-[14px] text-muted-foreground">{p.when}</p>}
</div>
{/* perforation between the two halves of the ticket */}
<div aria-hidden data-clip-ok className="relative h-0">
<span className="absolute -left-3 -top-3 size-6 rounded-full border border-border bg-background" />
<span className="absolute -right-3 -top-3 size-6 rounded-full border border-border bg-background" />
<span className="absolute inset-x-5 top-0 border-t border-dashed border-border-strong" />
</div>
<div className="p-6">
{left === 0 ? (
<LiveBadge live={p.live} t={t} />
) : (
<div role="timer" aria-label={parts ? fill(t.left, { d: parts.d, h: parts.h, m: parts.m }) : undefined} className="grid grid-cols-4 gap-2">
{cells.map(([v, label]) => (
<div key={label} aria-hidden className="rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))] px-1 py-3 text-center">
<span key={v ?? -1} className="block text-[24px] font-semibold tabular-nums leading-none tracking-[-0.03em] text-foreground animate-[cl-tick_.4s_var(--ease-kit)] motion-reduce:animate-none">
{v === null ? '--' : pad(v)}
</span>
<span className="mt-1.5 block truncate text-[10px] font-medium uppercase tracking-[0.04em] text-muted-foreground sm:text-[10.5px] sm:tracking-[0.1em]">{label}</span>
</div>
))}
<style>{'@keyframes cl-tick{from{opacity:.3;transform:translateY(-25%)}}'}</style>
</div>
)}
{seats && (
<div className="mt-5">
<p className="flex justify-between text-[13px]">
<span className="font-medium text-foreground">{fill(t.seatsLeft, { n: fmt(seats.left), t: fmt(seats.total) })}</span>
</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left animate-kit-progress rounded-full bg-primary [--kit-duration:1.2s] motion-reduce:animate-none" style={{ width: `${used}%` }} />
</div>
</div>
)}
<Button
size="lg"
variant={on ? 'outline' : 'primary'}
iconLeft={on ? 'check' : 'bell'}
aria-pressed={on}
onClick={() => setOn(!on)}
className="mt-5 w-full"
>
{on ? t.reminded : t.remind}
</Button>
{p.calendar && (
<a href={p.calendar.href} className="mt-2 flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg text-[14px] font-medium text-muted-foreground hover:bg-surface hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary">
<Icon name="calendar-check" size={16} />
{p.calendar.label}
</a>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<CountdownLaunchVariant, (p: ViewProps) => React.ReactNode> = {
'flip-board': FlipBoard,
rings: Rings,
'unlock-week': UnlockWeek,
ticket: Ticket,
};
export function CountdownLaunch({ variant = 'flip-board', labels, ...rest }: CountdownLaunchProps) {
const t = useTexts(TEXTS, labels);
const left = useLeft(rest.remaining, rest.endsAt);
const View = VIEWS[variant] ?? FlipBoard;
return <View {...rest} variant={variant} t={t} left={left} />;
}Häufige Fragen
Wie installiere ich Launch-Countdown?
Mit aiw add @ai-website/countdown-launch. Der Befehl legt die Datei components/ui/countdown-launch.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Launch-Countdown?
4 Varianten, Fallblatt-Anzeige, Ringe, Woche der Enthüllungen und Ticketkarte. Du wählst sie mit der Prop variant, zum Beispiel variant="rings".
Kann ich die Texte von Launch-Countdown übersetzen?
Ja. Launch-Countdown bringt 25 eigene Texte auf Deutsch und Englisch mit, etwa „Tage“, „Tag“ und „Tage“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Launch-Countdown einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Launch-Countdown mit aiw_components und installieren sie mit aiw_add @ai-website/countdown-launch. Nenn im Prompt die Variante, etwa variant="flip-board", dann schreibt der Assistent deine Inhalte direkt in die Props.