Skip to content
UI Kit
Accent color

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.

4 variantsOne of 3 components in Launch and waitlist

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

Split-flap boardNew in 1.2

Split-flap digits on a dark stage, each new digit folds down from the hinge. Below it an email field with error and success states and a link to the calendar.

RingsNew in 1.2

A large ring for the days and three small ones for hours, minutes and seconds, each empties with the time. A bell button turns a reminder on and off.

Unlock weekNew in 1.2

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 cardNew in 1.2

A compact card shaped like a ticket with a perforation, the clock in four tiles, seats left on a bar and a reminder button. Made for a side column or a launch page.

Details

What Launch countdown brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/countdown-launch.tsx
Brings along
utilskitbuttoniconsection
npm packages
None
Languages
English and German, 25 texts of its own
Studio
Adjust in code
In collections
Event

Props

The CountdownLaunchProps interface from components/ui/countdown-launch.tsx, with the comments from the code.

  • variantoptional
    CountdownLaunchVariant

    flip-boardringsunlock-weekticket

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • remainingoptional
    number

    Seconds left, counted from when the page shows

  • endsAtoptional
    string

    Or a fixed moment, e.g. 2026-11-20T18:00:00+01:00

  • spanDaysoptional
    number

    rings: the full span in days the day ring stands for (default 30)

  • whenoptional
    string

    flip-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

  • revealsoptional
    LaunchReveal[]

    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

  • formActionoptional
    string
  • noteoptional
    string

Built-in texts

Launch countdown writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 25 texts
daysEnglish DaysGerman Tage
day1English dayGerman Tag
daysWordEnglish daysGerman Tage
hoursWordEnglish hoursGerman Stunden
hour1English hourGerman Stunde
hoursEnglish HoursGerman Stunden
minutesEnglish MinutesGerman Minuten
secondsEnglish SecondsGerman Sekunden
leftEnglish {d} days, {h} hours, {m} minutes leftGerman Noch {d} Tage, {h} Stunden, {m} Minuten
emailEnglish Email addressGerman E-Mail-Adresse
emailPhEnglish you@example.comGerman du@beispiel.de
notifyEnglish Tell me when it startsGerman Zum Start benachrichtigen
sendingEnglish SavingGerman Wird gespeichert
invalidEnglish Please enter a full email address, like name@example.com.German Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@beispiel.de.
failedEnglish That did not go through. Please try again in a moment.German Das hat nicht geklappt. Bitte versuch es gleich noch einmal.
doneEnglish Done. We will write to you the moment it starts.German Erledigt. Wir schreiben dir, sobald es losgeht.
remindEnglish Remind meGerman Erinnern
remindedEnglish Reminder onGerman Erinnerung an
remindOffEnglish Turn the reminder offGerman Erinnerung ausschalten
liveNowEnglish Live nowGerman Jetzt live
opensInEnglish Opens in {t}German Öffnet in {t}
openEnglish OpenGerman Offen
nextOpenEnglish Next reveal inGerman Nächste Enthüllung in
seatsLeftEnglish {n} of {t} seats leftGerman Noch {n} von {t} Plätzen
lockedEnglish Locked until its dayGerman 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
components/ui/countdown-launch.tsx
'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.

Often searched

Jump to

Copied to the clipboard