Launch countdown
The moment before a launch with a clock that counts down: split-flap digits on a dark stage, rings that empty, a week of locked reveals and a ticket card with seats and a reminder.
The moment before the launch
A launch countdown turns a date into anticipation and collects the people who want to be there when it starts. The four sections here combine the clock with a way to stay in touch, an email field, a reminder or a seat.
Split-flap board shows split-flap digits on a dark stage, each new digit folds down from the hinge, with an email field with error and success states and a link to the calendar below. Rings uses a large ring for the days and three small ones for hours, minutes and seconds, each empties with the time, and a bell button turns a reminder on and off. Unlock week shows a week of reveals as cards, opened days link to their news, locked ones keep their title blurred and count down, the next one glows. Ticket card is a compact card shaped like a ticket with a perforation, the clock in four tiles, seats left on a bar and a reminder button.
Pair every countdown with one action, sign up, remind me or book a seat. Name the exact date and time zone next to the clock. When the countdown ends, switch the section to the launch itself, a clock at zero helps nobody.
Variants
Details
What Launch countdown brings along, read from the registry and the source.
- Category
- Launch and waitlist
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/countdown-launch.tsx
- npm packages
- None
- Languages
- English and German, 25 texts of its own
- Studio
- Adjust in code
- In collections
- Event
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CountdownLaunchProps interface from components/ui/countdown-launch.tsx, with the comments from the code.
variantoptionalCountdownLaunchVariantflip-boardringsunlock-weekticket
eyebrowoptionalReact.ReactNodetitlerequiredReact.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
Built-in texts
Launch countdown writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 25 texts
| Key | English | German |
|---|---|---|
| days | English Days | German Tage |
| day1 | English day | German Tag |
| daysWord | English days | German Tage |
| hoursWord | English hours | German Stunden |
| hour1 | English hour | German Stunde |
| hours | English Hours | German Stunden |
| minutes | English Minutes | German Minuten |
| seconds | English Seconds | German Sekunden |
| left | English {d} days, {h} hours, {m} minutes left | German Noch {d} Tage, {h} Stunden, {m} Minuten |
| English Email address | German E-Mail-Adresse | |
| emailPh | English you@example.com | German du@beispiel.de |
| notify | English Tell me when it starts | German Zum Start benachrichtigen |
| sending | English Saving | German Wird gespeichert |
| invalid | English Please enter a full email address, like name@example.com. | German Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@beispiel.de. |
| failed | English That did not go through. Please try again in a moment. | German Das hat nicht geklappt. Bitte versuch es gleich noch einmal. |
| done | English Done. We will write to you the moment it starts. | German Erledigt. Wir schreiben dir, sobald es losgeht. |
| remind | English Remind me | German Erinnern |
| reminded | English Reminder on | German Erinnerung an |
| remindOff | English Turn the reminder off | German Erinnerung ausschalten |
| liveNow | English Live now | German Jetzt live |
| opensIn | English Opens in {t} | German Öffnet in {t} |
| open | English Open | German Offen |
| nextOpen | English Next reveal in | German Nächste Enthüllung in |
| seatsLeft | English {n} of {t} seats left | German Noch {n} von {t} Plätzen |
| locked | English Locked until its day | German Bis zu seinem Tag verschlossen |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-invalid, aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the roles alert, status and timer.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- 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. 6 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, 584 lines
'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} />;
}Frequent questions
How do I install Launch countdown?
With aiw add @ai-website/countdown-launch. The command puts components/ui/countdown-launch.tsx into your project, the code is yours from then on. It brings along button, icon and section. It needs no further npm packages.
Which variants does Launch countdown have?
4 variants, Split-flap board, Rings, Unlock week and Ticket card. You pick one with the variant prop, for example variant="rings".
Can I translate the texts of Launch countdown?
Yes. Launch countdown carries 25 texts of its own in English and German, such as “Days”, “day” and “days”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Launch countdown?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Launch countdown with aiw_components and install it with aiw_add @ai-website/countdown-launch. Name the variant in your prompt, for example variant="flip-board", and the assistant writes your content straight into the props.