Skip to content
UI Kit
Accent color

Waitlist page

Waitlists that give people a reason to stay: a place in line with invite rewards, a short qualifying form, a crowd around one field and an invite code panel.

4 variantsOne of 3 components in Launch and waitlist

A reason to wait, and to share

A waitlist turns interest before a launch into a list of people who want in. The four versions here give people a reason to stay and to bring others, from a visible place in line to an invite code that opens the door at once.

Place in line with invites takes one field, then shows your place, an invite link to copy and the rungs to climb, the rail fills with every invite. Perks and short form puts what early members get on the left and a short form with name, email, role and team size on the right, every field says what is missing and the success shows the place and the next group date. Crowd around one field sets a large headline and one field in the middle with monograms of the people already waiting floating around it, and the count rises when you join. Invite code or request redeems a code in boxes that take a paste and move with the arrow keys, or requests access with a short note.

Ask for as little as you need, one field gets the most sign ups. Show the place in line right after joining, that is the moment people share. Write the next step plainly, when the next group gets in and how people hear about it.

Variants

Place in line with invitesNew in 1.2

Join with one field, then see your place, copy your invite link and climb the rungs. The place stays blurred until you are in, the rail fills with every invite.

Perks and short formNew in 1.2

What early members get on the left, a short form with name, email, role and team size on the right. Every field says what is missing, the success shows your place and the next group date.

Crowd around one fieldNew in 1.2

A large headline and one field in the middle, monograms of the people already waiting float around it. A small feed says who just joined, the count rises when you join.

Invite code or requestNew in 1.2

One panel, two ways in. Redeem a code in boxes that take a paste, move with the arrow keys and say exactly what is wrong, or request access with a short note.

Details

What Waitlist brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/waitlist.tsx
Brings along
utilskitmotionbuttoniconnumber-tickersection
npm packages
None
Languages
English and German, 42 texts of its own
Studio
Adjust in code

Props

The WaitlistProps interface from components/ui/waitlist.tsx, with the comments from the code.

  • variantoptional
    WaitlistVariant

    referral-queuesplit-formcrowdinvite-code

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

    People already waiting, counted up in the language format

  • positionoptional
    number

    referral-queue, split-form: the place a new sign-up gets

  • referraloptional
    { url: string; rewards: WaitlistReward[]; placesPerInvite?: number }

    referral-queue: the link people share, and what each number of invites unlocks

  • perksoptional
    WaitlistPerk[]

    split-form: perks with icons

  • rolesoptional
    string[]

    split-form: the role picker

  • sizesoptional
    string[]

    split-form: team sizes as chips

  • peopleoptional
    { name: string; place?: string }[]

    crowd: first names (or initials) of people who just joined, shown as monograms and in the feed

  • codeLengthoptional
    number

    invite-code: number of characters in a code

  • onRedeemoptional
    (code: string) => boolean | Promise<boolean>

    invite-code: checks a code, resolve true to accept. Without it any full code is accepted.

  • noteoptional
    string

    Short reassurance under the form

  • nextBatchoptional
    string

    split-form: when the next group gets in, e.g. Nov 18

  • formActionoptional
    string
  • onSubmitoptional
    (data: WaitlistData) => void | Promise<void>

    Resolve to show success, throw to show the error

  • invitesoptional
    number

    referral-queue: invites this person has sent that joined

  • joinedoptional
    boolean

    Studio preview: start in the joined state

Built-in texts

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

Show all 42 texts
emailEnglish Email addressGerman E-Mail-Adresse
emailPhEnglish you@company.comGerman du@firma.de
nameEnglish Your nameGerman Dein Name
namePhEnglish Alex MorganGerman Alex Morgen
roleEnglish What do you do?German Was machst du beruflich?
rolePickEnglish Pick your roleGerman Rolle wählen
sizeEnglish Team sizeGerman Teamgröße
joinEnglish Join the waitlistGerman Auf die Warteliste
joiningEnglish Saving your placeGerman Platz wird gesichert
invalidEmailEnglish Please enter a full email address, like name@company.com.German Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.
invalidNameEnglish Please tell us your name, so the invite is addressed to you.German Bitte nenn uns deinen Namen, damit die Einladung dich persönlich erreicht.
invalidRoleEnglish Please pick the role closest to yours.German Bitte wähle die Rolle, die deiner am nächsten kommt.
failedEnglish That did not go through. Your details are still here, please try again.German Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.
waitingEnglish people waitingGerman Menschen warten
yourPlaceEnglish Your place in lineGerman Dein Platz in der Reihe
youAreInEnglish You are on the listGerman Du stehst auf der Liste
shareEnglish Your invite linkGerman Dein Einladungslink
copyEnglish Copy linkGerman Link kopieren
copiedEnglish CopiedGerman Kopiert
moveUpEnglish Every friend who joins moves you up {n} places.German Jede Person, die über dich beitritt, bringt dich {n} Plätze nach vorn.
invitesEnglish {n} invitesGerman {n} Einladungen
invite1English 1 inviteGerman 1 Einladung
unlockedEnglish UnlockedGerman Freigeschaltet
ladderEnglish Move up the lineGerman In der Reihe vorrücken
ladderLockedEnglish Join to get your link and start moving up.German Trag dich ein, dann bekommst du deinen Link und rückst vor.
nextBatchEnglish Next group gets in on {date}. We email you the day before.German Die nächste Gruppe kommt am {date} rein. Wir schreiben dir am Tag davor.
justJoinedEnglish just joinedGerman ist gerade dazugekommen
fromEnglish fromGerman aus
requestEnglish Request accessGerman Zugang anfragen
redeemEnglish I have a codeGerman Ich habe einen Code
codeEnglish Invite codeGerman Einladungscode
codeHintEnglish The code is in your invite email, letters and digits.German Der Code steht in deiner Einladung, Buchstaben und Ziffern.
checkEnglish Unlock accessGerman Zugang freischalten
checkingEnglish Checking the codeGerman Code wird geprüft
codeShortEnglish The code has {n} characters. Please fill in every box.German Der Code hat {n} Zeichen. Bitte füll jedes Feld aus.
codeWrongEnglish That code does not open anything yet. Check the email or request access below.German Dieser Code öffnet noch nichts. Schau in die E-Mail oder frag unten den Zugang an.
codeOkEnglish Welcome in. Your account is ready.German Willkommen. Dein Konto ist bereit.
openEnglish Open my accountGerman Mein Konto öffnen
whyEnglish What would you use it for?German Wofür würdest du es nutzen?
whyOptionalEnglish A sentence helps us invite the right people first.German Ein Satz hilft uns, die Richtigen zuerst einzuladen.
sentEnglish Thanks. We read every request and answer within a week.German Danke. Wir lesen jede Anfrage und antworten innerhalb einer Woche.
waysEnglish Two ways inGerman Zwei Wege hinein

Accessibility

Read from the source of the component.

  • Waitlist handles the keys Arrow left, Arrow right, Backspace and Space itself.
  • Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles alert, group, status, tab, tablist and tabpanel.
  • 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, 914 lines
components/ui/waitlist.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Eyebrow, Section } from '@/components/ui/section';

/*
 * A waitlist that gives people a reason to stay. Four layouts:
 *
 *   referral-queue  join, see your place, move up the line by inviting friends
 *   split-form      perks on the left, a short qualifying form on the right
 *   crowd           one field in the middle, the people already waiting around it
 *   invite-code     request access or redeem a code, in one segmented panel
 *
 * Every form validates in place, says what went wrong in plain words and
 * ends in a success state. Without onSubmit the forms post to formAction.
 */

export type WaitlistVariant = 'referral-queue' | 'split-form' | 'crowd' | 'invite-code';

export interface WaitlistReward {
  /** Invites needed */
  invites: number;
  reward: string;
}

export interface WaitlistPerk {
  icon?: IconName;
  title: string;
  text?: string;
}

export interface WaitlistData {
  email: string;
  name?: string;
  role?: string;
  size?: string;
  code?: string;
}

export interface WaitlistProps {
  variant?: WaitlistVariant;
  eyebrow?: React.ReactNode;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** People already waiting, counted up in the language format */
  count?: number;
  /** referral-queue, split-form: the place a new sign-up gets */
  position?: number;
  /** referral-queue: the link people share, and what each number of invites unlocks */
  referral?: { url: string; rewards: WaitlistReward[]; placesPerInvite?: number };
  /** split-form: perks with icons */
  perks?: WaitlistPerk[];
  /** split-form: the role picker */
  roles?: string[];
  /** split-form: team sizes as chips */
  sizes?: string[];
  /** crowd: first names (or initials) of people who just joined, shown as monograms and in the feed */
  people?: { name: string; place?: string }[];
  /** invite-code: number of characters in a code */
  codeLength?: number;
  /** invite-code: checks a code, resolve true to accept. Without it any full code is accepted. */
  onRedeem?: (code: string) => boolean | Promise<boolean>;
  /** Short reassurance under the form */
  note?: string;
  /** split-form: when the next group gets in, e.g. Nov 18 */
  nextBatch?: string;
  formAction?: string;
  /** Resolve to show success, throw to show the error */
  onSubmit?: (data: WaitlistData) => void | Promise<void>;
  /** referral-queue: invites this person has sent that joined */
  invites?: number;
  /** Studio preview: start in the joined state */
  joined?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    email: 'Email address',
    emailPh: 'you@company.com',
    name: 'Your name',
    namePh: 'Alex Morgan',
    role: 'What do you do?',
    rolePick: 'Pick your role',
    size: 'Team size',
    join: 'Join the waitlist',
    joining: 'Saving your place',
    invalidEmail: 'Please enter a full email address, like name@company.com.',
    invalidName: 'Please tell us your name, so the invite is addressed to you.',
    invalidRole: 'Please pick the role closest to yours.',
    failed: 'That did not go through. Your details are still here, please try again.',
    waiting: 'people waiting',
    yourPlace: 'Your place in line',
    youAreIn: 'You are on the list',
    share: 'Your invite link',
    copy: 'Copy link',
    copied: 'Copied',
    moveUp: 'Every friend who joins moves you up {n} places.',
    invites: '{n} invites',
    invite1: '1 invite',
    unlocked: 'Unlocked',
    ladder: 'Move up the line',
    ladderLocked: 'Join to get your link and start moving up.',
    nextBatch: 'Next group gets in on {date}. We email you the day before.',
    justJoined: 'just joined',
    from: 'from',
    request: 'Request access',
    redeem: 'I have a code',
    code: 'Invite code',
    codeHint: 'The code is in your invite email, letters and digits.',
    check: 'Unlock access',
    checking: 'Checking the code',
    codeShort: 'The code has {n} characters. Please fill in every box.',
    codeWrong: 'That code does not open anything yet. Check the email or request access below.',
    codeOk: 'Welcome in. Your account is ready.',
    open: 'Open my account',
    why: 'What would you use it for?',
    whyOptional: 'A sentence helps us invite the right people first.',
    sent: 'Thanks. We read every request and answer within a week.',
    ways: 'Two ways in',
  },
  de: {
    email: 'E-Mail-Adresse',
    emailPh: 'du@firma.de',
    name: 'Dein Name',
    namePh: 'Alex Morgen',
    role: 'Was machst du beruflich?',
    rolePick: 'Rolle wählen',
    size: 'Teamgröße',
    join: 'Auf die Warteliste',
    joining: 'Platz wird gesichert',
    invalidEmail: 'Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.',
    invalidName: 'Bitte nenn uns deinen Namen, damit die Einladung dich persönlich erreicht.',
    invalidRole: 'Bitte wähle die Rolle, die deiner am nächsten kommt.',
    failed: 'Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.',
    waiting: 'Menschen warten',
    yourPlace: 'Dein Platz in der Reihe',
    youAreIn: 'Du stehst auf der Liste',
    share: 'Dein Einladungslink',
    copy: 'Link kopieren',
    copied: 'Kopiert',
    moveUp: 'Jede Person, die über dich beitritt, bringt dich {n} Plätze nach vorn.',
    invites: '{n} Einladungen',
    invite1: '1 Einladung',
    unlocked: 'Freigeschaltet',
    ladder: 'In der Reihe vorrücken',
    ladderLocked: 'Trag dich ein, dann bekommst du deinen Link und rückst vor.',
    nextBatch: 'Die nächste Gruppe kommt am {date} rein. Wir schreiben dir am Tag davor.',
    justJoined: 'ist gerade dazugekommen',
    from: 'aus',
    request: 'Zugang anfragen',
    redeem: 'Ich habe einen Code',
    code: 'Einladungscode',
    codeHint: 'Der Code steht in deiner Einladung, Buchstaben und Ziffern.',
    check: 'Zugang freischalten',
    checking: 'Code wird geprüft',
    codeShort: 'Der Code hat {n} Zeichen. Bitte füll jedes Feld aus.',
    codeWrong: 'Dieser Code öffnet noch nichts. Schau in die E-Mail oder frag unten den Zugang an.',
    codeOk: 'Willkommen. Dein Konto ist bereit.',
    open: 'Mein Konto öffnen',
    why: 'Wofür würdest du es nutzen?',
    whyOptional: 'Ein Satz hilft uns, die Richtigen zuerst einzuladen.',
    sent: 'Danke. Wir lesen jede Anfrage und antworten innerhalb einer Woche.',
    ways: 'Zwei Wege hinein',
  },
};

type Texts = (typeof TEXTS)['en'];
type ViewProps = WaitlistProps & { t: Texts };

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
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 wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((w) => w[0])
    .slice(0, 2)
    .join('')
    .toUpperCase();
const SHAKE = '@keyframes wl-shake{20%,60%{transform:translateX(-6px)}40%,80%{transform:translateX(6px)}}';
const FIELD =
  'h-12 w-full min-w-0 rounded-lg border bg-background px-4 text-base text-foreground placeholder:text-subtle-foreground transition-[border-color,box-shadow] duration-200 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px]';

function useFormat() {
  const locale = useLocale();
  return (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
}

function Head({ eyebrow, title, description, center, className }: Pick<WaitlistProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
  return (
    <div className={cn('max-w-[40rem]', center && 'mx-auto text-center', className)}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{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 FormError({ id, children }: { id: string; children: React.ReactNode }) {
  return (
    <p id={id} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
      <span>{children}</span>
    </p>
  );
}

/** A small round mark with initials, toned from the accent so a row of them stays calm */
function Monogram({ name, index, className, style }: { name: string; index: number; className?: string; style?: React.CSSProperties }) {
  const mix = [18, 30, 44, 24, 36][index % 5];
  return (
    <span
      aria-hidden
      className={cn('grid size-9 shrink-0 place-items-center rounded-full border-2 border-background text-[11px] font-semibold text-foreground', className)}
      style={{ background: `color-mix(in oklab, var(--primary) ${mix}%, var(--card))`, ...style }}
    >
      {initials(name)}
    </span>
  );
}

function Spinner() {
  return <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />;
}

/** One email field and a button, the shape most waitlists need */
function EmailRow({ t, onDone, formAction, onSubmit, big }: { t: Texts; onDone: (email: string) => void; formAction?: string; onSubmit?: WaitlistProps['onSubmit']; big?: boolean }) {
  const id = useId();
  const [state, setState] = useState<'idle' | 'invalid' | 'sending' | 'failed'>('idle');
  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    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 (!onSubmit && formAction) return;
    e.preventDefault();
    setState('sending');
    try {
      await (onSubmit ? onSubmit({ email }) : wait(800));
      onDone(email);
    } catch {
      setState('failed');
    }
  };
  const bad = state === 'invalid';
  return (
    <form action={formAction} method="post" noValidate onSubmit={submit} className="w-full">
      <style>{SHAKE}</style>
      <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:transition-[border-color,box-shadow] sm:focus-within:shadow-[0_0_0_4px_var(--glow),var(--shadow-md)]',
          bad ? 'animate-[wl-shake_.4s_var(--ease-kit)] sm:border-destructive motion-reduce:animate-none' : 'sm:border-border sm:focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))]',
        )}
      >
        <input
          id={id}
          name="email"
          type="email"
          autoComplete="email"
          inputMode="email"
          required
          placeholder={t.emailPh}
          aria-invalid={bad || undefined}
          aria-describedby={bad || state === 'failed' ? `${id}-e` : undefined}
          onInput={() => bad && setState('idle')}
          className={cn(FIELD, 'bg-card sm:h-11 sm:flex-1 sm:border-0 sm:bg-transparent sm:shadow-none sm:focus-visible:shadow-none', big && 'sm:h-12 sm:text-[16px]', bad ? 'border-destructive' : 'border-border')}
        />
        <Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className={cn('max-sm:w-full sm:h-11', big && 'sm:h-12')}>
          {state === 'sending' && <Spinner />}
          {state === 'sending' ? t.joining : t.join}
        </Button>
      </div>
      {(bad || state === 'failed') && <FormError id={`${id}-e`}>{bad ? t.invalidEmail : t.failed}</FormError>}
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* referral-queue                                                      */
/* ------------------------------------------------------------------ */

function CopyLink({ url, t }: { url: string; t: Texts }) {
  const id = useId();
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(url);
    } catch {
      /* the field stays selectable */
    }
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1800);
  };
  return (
    <div>
      <label htmlFor={id} className="text-[13px] font-medium text-muted-foreground">
        {t.share}
      </label>
      <div className="mt-2 flex gap-2">
        <input id={id} readOnly value={url} onFocus={(e) => e.currentTarget.select()} className={cn(FIELD, 'h-11 border-border bg-surface text-[14px] sm:text-[14px]')} />
        <button
          type="button"
          onClick={copy}
          className="inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3.5 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-[background-color,transform] hover:bg-accent active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary"
        >
          <Icon name={copied ? 'check' : 'copy'} size={16} className={cn(copied && 'text-success')} />
          <span aria-live="polite">{copied ? t.copied : t.copy}</span>
        </button>
      </div>
    </div>
  );
}

function ReferralQueue(p: ViewProps) {
  const { t } = p;
  const format = useFormat();
  const [email, setEmail] = useState<string | null>(p.joined ? 'demo' : null);
  const joined = email !== null;
  const rewards = p.referral?.rewards ?? [];
  const per = p.referral?.placesPerInvite ?? 120;
  const invites = joined ? (p.invites ?? 0) : 0;
    const place = Math.max(1, (p.position ?? 2481) - invites * per);
  const max = rewards.length ? rewards[rewards.length - 1].invites : 1;
  return (
    <Section background="glow" className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,27rem)] lg:gap-20">
        <div>
          <Head {...p} />
          <div className="kit-rise mt-9 max-w-[30rem]" style={delay(160)}>
            {joined ? (
              <div role="status" className="kit-rise flex items-start 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))] p-4 text-[14.5px] text-foreground">
                <Icon name="check-circle" size={20} className="mt-px shrink-0 text-success" />
                <span className="text-pretty">
                  <strong className="font-semibold">{t.youAreIn}.</strong> {fill(t.moveUp, { n: per })}
                </span>
              </div>
            ) : (
              <EmailRow t={t} onDone={setEmail} formAction={p.formAction} onSubmit={p.onSubmit} />
            )}
            {!joined && p.count !== undefined && (
              <p className="mt-4 flex items-center gap-2 text-[13.5px] text-muted-foreground">
                <span className="relative flex size-2" aria-hidden>
                  <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
                  <span className="relative size-2 rounded-full bg-success" />
                </span>
                <span>
                  <NumberTicker value={p.count} className="font-semibold tabular-nums text-foreground" /> {t.waiting}
                </span>
              </p>
            )}
            {p.note && !joined && <p className="mt-2 text-[13px] text-subtle-foreground">{p.note}</p>}
          </div>
        </div>

        {/* the line: your place, your link and the rungs above you */}
        <div className="kit-rise relative" style={delay(220)}>
          <div className="relative overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
            <div className="border-b border-border p-6 sm:p-7">
              <p className="text-[13px] font-medium text-muted-foreground">{t.yourPlace}</p>
              <p className={cn('mt-2 flex items-baseline gap-1 font-semibold leading-none tracking-[-0.04em] tabular-nums transition-[color,filter] duration-500', joined ? 'text-foreground' : 'text-subtle-foreground blur-[5px] select-none')} aria-hidden={!joined || undefined}>
                <span className="text-[28px] text-subtle-foreground">#</span>
                <span key={place} className={cn('text-[clamp(3rem,2.4rem+2.4vw,4.25rem)]', joined && 'animate-[wl-pop_.5s_var(--ease-kit)] motion-reduce:animate-none')}>
                  {format(place)}
                </span>
              </p>
              <style>{'@keyframes wl-pop{from{opacity:.2;transform:translateY(30%)}}'}</style>
              {joined ? (
                <div className="mt-6">
                  <CopyLink url={p.referral?.url ?? ''} t={t} />
                  <p className="mt-3 flex items-center gap-1.5 text-[13px] text-muted-foreground">
                    <Icon name="user-plus" size={15} />
                    {invites === 1 ? t.invite1 : fill(t.invites, { n: invites })}
                  </p>
                </div>
              ) : (
                <p className="mt-4 text-pretty text-[14px] text-muted-foreground">{t.ladderLocked}</p>
              )}
            </div>
            <div className="p-6 sm:p-7">
              <p className="text-[13px] font-medium text-muted-foreground">{t.ladder}</p>
              <ol className="relative mt-4 grid gap-4">
                <span aria-hidden className="absolute bottom-3 left-[15px] top-3 w-px bg-border" />
                <span aria-hidden className="absolute left-[15px] top-3 w-px origin-top bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ height: 'calc(100% - 1.5rem)', transform: `scaleY(${Math.min(1, invites / Math.max(1, max))})` }} />
                {rewards.map((r, i) => {
                  const on = joined && invites >= r.invites;
                  return (
                    <li key={i} className="relative flex items-center gap-3.5">
                      <span className={cn('relative z-[1] grid size-[31px] shrink-0 place-items-center rounded-full border text-[12px] font-semibold transition-[background-color,border-color,color] duration-500', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-card text-muted-foreground')}>
                        {on ? <Icon name="check" size={14} strokeWidth={2.2} /> : r.invites}
                      </span>
                      <span className="min-w-0 flex-1">
                        <span className="block text-pretty text-[14.5px] font-medium text-foreground">{r.reward}</span>
                        <span className="block text-[12.5px] text-muted-foreground">{on ? t.unlocked : r.invites === 1 ? t.invite1 : fill(t.invites, { n: r.invites })}</span>
                      </span>
                    </li>
                  );
                })}
              </ol>
            </div>
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* split-form                                                          */
/* ------------------------------------------------------------------ */

function SplitForm(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const format = useFormat();
  const [errors, setErrors] = useState<Partial<Record<'name' | 'email' | 'role', string>>>({});
  const [state, setState] = useState<'idle' | 'sending' | 'failed' | 'done'>(p.joined ? 'done' : 'idle');
  const [size, setSize] = useState(0);
  const formRef = useRef<HTMLFormElement>(null);
  const sizes = p.sizes ?? [];
  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    const f = new FormData(e.currentTarget);
    const data: WaitlistData = { name: String(f.get('name') ?? '').trim(), email: String(f.get('email') ?? '').trim(), role: String(f.get('role') ?? ''), size: sizes[size] };
    const next: typeof errors = {};
    if (!data.name) next.name = t.invalidName;
    if (!EMAIL.test(data.email)) next.email = t.invalidEmail;
    if (p.roles?.length && !data.role) next.role = t.invalidRole;
    setErrors(next);
    const first = Object.keys(next)[0];
    if (first) {
      e.preventDefault();
      (e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus();
      return;
    }
    if (!p.onSubmit && p.formAction) return;
    e.preventDefault();
    setState('sending');
    try {
      await (p.onSubmit ? p.onSubmit(data) : wait(900));
      setState('done');
    } catch {
      setState('failed');
    }
  };
  const err = (k: 'name' | 'email' | 'role') =>
    errors[k] ? { 'aria-invalid': true as const, 'aria-describedby': `${id}-${k}-e` } : {};
  const clear = (k: 'name' | 'email' | 'role') => errors[k] && setErrors((x) => ({ ...x, [k]: undefined }));
  return (
    <Section className={p.className}>
      <div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,30rem)] lg:gap-20">
        <div className="lg:pt-4">
          <Head {...p} />
          {p.perks?.length ? (
            <ul className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-1">
              {p.perks.map((perk, i) => (
                <li key={i} className="kit-rise flex gap-4" style={delay(160 + i * 80)}>
                  <span className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-card text-primary shadow-[var(--highlight),var(--shadow-sm)]">
                    <Icon name={perk.icon ?? 'check'} size={18} />
                  </span>
                  <span>
                    <span className="block text-[15.5px] font-semibold text-foreground">{perk.title}</span>
                    {perk.text && <span className="mt-1 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{perk.text}</span>}
                  </span>
                </li>
              ))}
            </ul>
          ) : null}
        </div>

        <div className="kit-rise relative" style={delay(200)}>
          <div aria-hidden className="pointer-events-none absolute -inset-6 -z-10 rounded-[2rem] bg-[radial-gradient(50%_45%_at_50%_45%,var(--glow),transparent_100%)]" />
          <div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
            {state === 'done' ? (
              <div role="status" className="kit-rise py-6 text-center">
                <span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success">
                  <Icon name="check" size={26} strokeWidth={2} />
                </span>
                <p className="mt-5 text-[20px] font-semibold text-foreground">{t.youAreIn}</p>
                {p.position !== undefined && (
                  <p className="mt-2 text-[15px] text-muted-foreground">
                    {t.yourPlace} <strong className="font-semibold tabular-nums text-foreground">#{format(p.position)}</strong>
                  </p>
                )}
                {p.nextBatch && <p className="mx-auto mt-5 max-w-[22rem] text-pretty rounded-xl bg-surface px-4 py-3 text-[14px] text-muted-foreground">{fill(t.nextBatch, { date: p.nextBatch })}</p>}
              </div>
            ) : (
              <form ref={formRef} action={p.formAction} method="post" noValidate onSubmit={submit} className="grid gap-5">
                <style>{SHAKE}</style>
                <div>
                  <label htmlFor={`${id}-name`} className="text-sm font-medium text-foreground">
                    {t.name}
                  </label>
                  <input id={`${id}-name`} name="name" autoComplete="name" placeholder={t.namePh} onInput={() => clear('name')} {...err('name')} className={cn(FIELD, 'mt-2', errors.name ? 'border-destructive' : 'border-border')} />
                  {errors.name && <FormError id={`${id}-name-e`}>{errors.name}</FormError>}
                </div>
                <div>
                  <label htmlFor={`${id}-email`} className="text-sm font-medium text-foreground">
                    {t.email}
                  </label>
                  <input id={`${id}-email`} name="email" type="email" inputMode="email" autoComplete="email" placeholder={t.emailPh} onInput={() => clear('email')} {...err('email')} className={cn(FIELD, 'mt-2', errors.email ? 'border-destructive' : 'border-border')} />
                  {errors.email && <FormError id={`${id}-email-e`}>{errors.email}</FormError>}
                </div>
                {p.roles?.length ? (
                  <div>
                    <label htmlFor={`${id}-role`} className="text-sm font-medium text-foreground">
                      {t.role}
                    </label>
                    <span className="relative mt-2 block">
                      <select id={`${id}-role`} name="role" defaultValue="" onChange={() => clear('role')} {...err('role')} className={cn(FIELD, 'cursor-pointer appearance-none pr-10', errors.role ? 'border-destructive' : 'border-border')}>
                        <option value="" disabled>
                          {t.rolePick}
                        </option>
                        {p.roles.map((r) => (
                          <option key={r} value={r}>
                            {r}
                          </option>
                        ))}
                      </select>
                      <Icon name="chevrons-up-down" size={16} className="pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
                    </span>
                    {errors.role && <FormError id={`${id}-role-e`}>{errors.role}</FormError>}
                  </div>
                ) : null}
                {sizes.length ? (
                  <fieldset className="min-w-0">
                    <legend className="text-sm font-medium text-foreground">{t.size}</legend>
                    <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
                      {sizes.map((s, i) => (
                        <label key={s} className={cn('relative flex h-11 cursor-pointer items-center justify-center rounded-lg border text-[14px] font-medium transition-[background-color,border-color,color,box-shadow] duration-200 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary', size === i ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground shadow-[0_0_0_3px_var(--glow)]' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
                          <input type="radio" name="size" value={s} checked={size === i} onChange={() => setSize(i)} className="sr-only" />
                          {s}
                        </label>
                      ))}
                    </div>
                  </fieldset>
                ) : null}
                <Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className="mt-1 w-full">
                  {state === 'sending' && <Spinner />}
                  {state === 'sending' ? t.joining : t.join}
                </Button>
                {state === 'failed' && <FormError id={`${id}-f`}>{t.failed}</FormError>}
                {p.note && (
                  <p className="-mt-1 flex items-center justify-center gap-1.5 text-center text-[13px] text-muted-foreground">
                    <Icon name="lock" size={13} />
                    {p.note}
                  </p>
                )}
              </form>
            )}
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* crowd                                                               */
/* ------------------------------------------------------------------ */

/** Monograms placed on two calm rings around the form, only on wide screens */
const SEATS = [
  [8, 22], [16, 64], [4, 46], [22, 86], [27, 12], [90, 20], [84, 70], [96, 48], [74, 88], [71, 10], [36, 94], [62, 4],
];

function Crowd(p: ViewProps) {
  const { t } = p;
  const people = p.people ?? [];
  const [joined, setJoined] = useState(Boolean(p.joined));
  const [k, setK] = useState(0);
  const [ref, inView] = useInView<HTMLDivElement>();
  useEffect(() => {
    if (people.length < 2 || !inView || prefersReducedMotion()) return;
    const id = window.setInterval(() => setK((x) => (x + 1) % people.length), 3200);
    return () => window.clearInterval(id);
  }, [people.length, inView]);
  const now = people[k];
  return (
    <section className={cn('kit-dots relative overflow-hidden py-24 sm:py-32 lg:py-40', p.className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(48%_55%_at_50%_45%,var(--glow),transparent_72%)]" />
      <div ref={ref} aria-hidden className="pointer-events-none absolute inset-0 max-lg:hidden">
        {SEATS.slice(0, Math.min(SEATS.length, people.length)).map(([x, y], i) => (
          <Monogram
            key={i}
            name={people[i].name}
            index={i}
            className={cn('absolute size-11 -translate-x-1/2 -translate-y-1/2 text-[12px] shadow-[var(--shadow-md)] transition-[opacity,transform] duration-700 ease-[var(--ease-kit)]', inView ? 'opacity-100' : 'scale-75 opacity-0', 'animate-kit-float motion-reduce:animate-none')}
            style={{ left: `${x}%`, top: `${y}%`, transitionDelay: `${200 + i * 70}ms`, animationDelay: `${-i * 0.7}s`, animationDuration: `${6 + (i % 4)}s`, opacity: inView ? 0.35 + ((i * 37) % 60) / 100 : undefined }}
          />
        ))}
      </div>
      <div className="kit-container relative">
        <div className="mx-auto max-w-[40rem] text-center">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h1 font-semibold text-foreground">{p.title}</h2>
          {p.description && (
            <p className="kit-rise mx-auto mt-5 max-w-[32rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>
              {p.description}
            </p>
          )}
          <div className="kit-rise mx-auto mt-9 max-w-[30rem] text-left" style={delay(160)}>
            {joined ? (
              <p role="status" className="kit-rise flex items-center justify-center gap-2.5 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.5 text-[15px] font-medium text-foreground">
                <Icon name="check-circle" size={19} className="text-success" />
                {t.youAreIn}
              </p>
            ) : (
              <EmailRow t={t} big onDone={() => setJoined(true)} formAction={p.formAction} onSubmit={p.onSubmit} />
            )}
          </div>
          <div className="kit-rise mt-8 flex flex-col items-center gap-4" style={delay(240)}>
            <div className="flex items-center gap-3">
              <span className="flex -space-x-2.5">
                {people.slice(0, 5).map((x, i) => (
                  <Monogram key={i} name={x.name} index={i} />
                ))}
              </span>
              {p.count !== undefined && (
                <p className="text-left text-[14px] leading-tight text-muted-foreground">
                  <span className="block text-[17px] font-semibold tabular-nums text-foreground">
                    <NumberTicker value={p.count + (joined ? 1 : 0)} />
                  </span>
                  <span className="block">{t.waiting}</span>
                </p>
              )}
            </div>
            {now && (
              <p aria-live="off" className="flex h-8 items-center overflow-hidden rounded-full border border-border bg-card px-3 text-[13px] text-muted-foreground shadow-[var(--shadow-sm)]">
                <span key={k} className="flex items-center gap-2 animate-[wl-feed_.6s_var(--ease-kit)] motion-reduce:animate-none">
                  <span className="size-1.5 rounded-full bg-success" aria-hidden />
                  <strong className="font-medium text-foreground">{now.name}</strong>
                  {now.place && (
                    <span className="max-[360px]:hidden">
                      {t.from} {now.place}
                    </span>
                  )}
                  <span>{t.justJoined}</span>
                </span>
              </p>
            )}
            <style>{'@keyframes wl-feed{from{opacity:0;transform:translateY(100%)}}'}</style>
          </div>
          {p.note && <p className="mt-4 text-[13px] text-subtle-foreground">{p.note}</p>}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* invite-code                                                         */
/* ------------------------------------------------------------------ */

function CodeBoxes({ length, value, onChange, invalid, describedBy, label }: { length: number; value: string[]; onChange: (v: string[]) => void; invalid: boolean; describedBy?: string; label: string }) {
  const refs = useRef<(HTMLInputElement | null)[]>([]);
  const set = (i: number, chars: string) => {
    const clean = chars.toUpperCase().replace(/[^A-Z0-9]/g, '');
    if (!clean) return;
    const next = [...value];
    for (let j = 0; j < clean.length && i + j < length; j++) next[i + j] = clean[j];
    onChange(next);
    refs.current[Math.min(length - 1, i + clean.length)]?.focus();
  };
  const half = Math.ceil(length / 2);
  return (
    <div role="group" aria-label={label} className="flex items-center justify-center gap-1 sm:gap-2">
      {Array.from({ length }, (_, i) => (
        <span key={i} className="contents">
          {i === half && <span aria-hidden className="h-px w-2 shrink-0 bg-border-strong sm:mx-1 sm:w-3" />}
          <input
            ref={(el) => {
              refs.current[i] = el;
            }}
            value={value[i] ?? ''}
            inputMode="text"
            autoCapitalize="characters"
            autoComplete={i === 0 ? 'one-time-code' : 'off'}
                        aria-label={`${label} ${i + 1}`}
            aria-invalid={invalid || undefined}
            aria-describedby={describedBy}
            onChange={(e) => {
              const v = e.target.value;
              // typing into a filled box keeps only the new character, a longer value (autofill) spreads over the boxes
              set(i, v.length > 1 && value[i] && v.startsWith(value[i]) ? v.slice(1) : v);
            }}
            onPaste={(e) => {
              e.preventDefault();
              set(i, e.clipboardData.getData('text'));
            }}
            onKeyDown={(e) => {
              if (e.key === 'Backspace' && !value[i] && i > 0) {
                const next = [...value];
                next[i - 1] = '';
                onChange(next);
                refs.current[i - 1]?.focus();
              }
              if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
              if (e.key === 'ArrowRight' && i < length - 1) refs.current[i + 1]?.focus();
            }}
            onFocus={(e) => e.currentTarget.select()}
            className={cn(
              'h-12 w-11 min-w-0 rounded-lg border bg-background text-center text-[20px] font-semibold uppercase text-foreground caret-primary transition-[border-color,box-shadow,transform] duration-200 focus-visible:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:h-14 sm:w-12 sm:text-[22px]',
              invalid ? 'border-destructive' : value[i] ? 'border-border-strong' : 'border-border',
            )}
          />
        </span>
      ))}
    </div>
  );
}

function InviteCode(p: ViewProps) {
  const { t } = p;
  const id = useId();
  const length = p.codeLength ?? 6;
  const [tab, setTab] = useState<0 | 1>(1);
  const [code, setCode] = useState<string[]>(Array(length).fill(''));
  const [codeState, setCodeState] = useState<'idle' | 'short' | 'wrong' | 'checking' | 'ok'>('idle');
  const [request, setRequest] = useState<'idle' | 'invalid' | 'sending' | 'failed' | 'done'>('idle');
  const tabs = [t.request, t.redeem];
  const redeem = async (e: React.FormEvent) => {
    e.preventDefault();
    const value = code.join('');
    if (value.length < length) return setCodeState('short');
    setCodeState('checking');
    const ok = p.onRedeem ? await p.onRedeem(value) : (await wait(700), true);
    setCodeState(ok ? 'ok' : 'wrong');
  };
  const ask = async (e: React.FormEvent<HTMLFormElement>) => {
    const f = new FormData(e.currentTarget);
    const email = String(f.get('email') ?? '').trim();
    if (!EMAIL.test(email)) {
      e.preventDefault();
      setRequest('invalid');
      (e.currentTarget.elements.namedItem('email') as HTMLElement | null)?.focus();
      return;
    }
    if (!p.onSubmit && p.formAction) return;
    e.preventDefault();
    setRequest('sending');
    try {
      await (p.onSubmit ? p.onSubmit({ email }) : wait(800));
      setRequest('done');
    } catch {
      setRequest('failed');
    }
  };
  const codeError = codeState === 'short' ? fill(t.codeShort, { n: length }) : codeState === 'wrong' ? t.codeWrong : null;
  return (
    <Section background="dots" className={p.className}>
      <Head {...p} center />
      <div className="kit-rise mx-auto mt-12 max-w-[32rem]" style={delay(160)}>
        <div className="kit-beam relative rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
          <div
            role="tablist"
            aria-label={t.ways}
            className="m-2 grid grid-cols-2 gap-1 rounded-xl bg-surface p-1"
            onKeyDown={(e) => {
              if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
              e.preventDefault();
              const next = tab === 0 ? 1 : 0;
              setTab(next);
              (e.currentTarget.querySelectorAll('[role="tab"]')[next] as HTMLElement | undefined)?.focus();
            }}
          >
            {tabs.map((label, i) => (
              <button
                key={i}
                type="button"
                role="tab"
                id={`${id}-t${i}`}
                aria-selected={tab === i}
                aria-controls={`${id}-p${i}`}
                tabIndex={tab === i ? 0 : -1}
                onClick={() => setTab(i as 0 | 1)}
                className={cn(
                  'h-11 cursor-pointer rounded-lg text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300 ease-[var(--ease-kit)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary',
                  tab === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground',
                )}
              >
                {label}
              </button>
            ))}
          </div>

          <div role="tabpanel" id={`${id}-p1`} aria-labelledby={`${id}-t1`} hidden={tab !== 1} className="px-4 pb-7 pt-5 sm:px-8">
            {codeState === 'ok' ? (
              <div role="status" className="kit-rise py-4 text-center">
                <span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success">
                  <Icon name="unlock" size={24} />
                </span>
                <p className="mt-4 text-[18px] font-semibold text-foreground">{t.codeOk}</p>
                <Button href="#" size="lg" icon="arrow-right" className="mt-6 max-sm:w-full">
                  {t.open}
                </Button>
              </div>
            ) : (
              <form noValidate onSubmit={redeem}>
                <style>{SHAKE}</style>
                <p id={`${id}-cl`} className="text-center text-sm font-medium text-foreground">
                  {t.code}
                </p>
                <div className={cn('mt-4', codeError && 'animate-[wl-shake_.4s_var(--ease-kit)] motion-reduce:animate-none')}>
                  <CodeBoxes
                    length={length}
                    value={code}
                    label={t.code}
                    invalid={Boolean(codeError)}
                    describedBy={`${id}-ch`}
                    onChange={(v) => {
                      setCode(v);
                      if (codeError) setCodeState('idle');
                    }}
                  />
                </div>
                <div id={`${id}-ch`} aria-live="polite" className="mt-3 min-h-10 text-center">
                  {codeError ? (
                    <p className="inline-flex items-start gap-1.5 text-left text-[13px] font-medium leading-5 text-destructive">
                      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                      {codeError}
                    </p>
                  ) : (
                    <p className="text-[13px] text-muted-foreground">{t.codeHint}</p>
                  )}
                </div>
                <Button type="submit" size="lg" disabled={codeState === 'checking'} icon={codeState === 'checking' ? undefined : 'key'} className="mt-3 w-full">
                  {codeState === 'checking' && <Spinner />}
                  {codeState === 'checking' ? t.checking : t.check}
                </Button>
              </form>
            )}
          </div>

          <div role="tabpanel" id={`${id}-p0`} aria-labelledby={`${id}-t0`} hidden={tab !== 0} className="px-6 pb-7 pt-5 sm:px-8">
            {request === 'done' ? (
              <p role="status" className="kit-rise flex items-start gap-3 rounded-xl bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] p-4 text-[14.5px] text-foreground">
                <Icon name="check-circle" size={19} className="mt-px shrink-0 text-success" />
                {t.sent}
              </p>
            ) : (
              <form action={p.formAction} method="post" noValidate onSubmit={ask} className="grid gap-5">
                <div>
                  <label htmlFor={`${id}-em`} className="text-sm font-medium text-foreground">
                    {t.email}
                  </label>
                  <input
                    id={`${id}-em`}
                    name="email"
                    type="email"
                    inputMode="email"
                    autoComplete="email"
                    placeholder={t.emailPh}
                    aria-invalid={request === 'invalid' || undefined}
                    aria-describedby={request === 'invalid' ? `${id}-em-e` : undefined}
                    onInput={() => request === 'invalid' && setRequest('idle')}
                    className={cn(FIELD, 'mt-2', request === 'invalid' ? 'border-destructive' : 'border-border')}
                  />
                  {request === 'invalid' && <FormError id={`${id}-em-e`}>{t.invalidEmail}</FormError>}
                </div>
                <div>
                  <label htmlFor={`${id}-why`} className="text-sm font-medium text-foreground">
                    {t.why}
                  </label>
                  <textarea id={`${id}-why`} name="why" rows={3} aria-describedby={`${id}-why-h`} className={cn(FIELD, 'mt-2 h-auto resize-none border-border py-3 leading-relaxed')} />
                  <p id={`${id}-why-h`} className="mt-1.5 text-[13px] text-muted-foreground">
                    {t.whyOptional}
                  </p>
                </div>
                <Button type="submit" size="lg" disabled={request === 'sending'} icon={request === 'sending' ? undefined : 'send'} className="w-full">
                  {request === 'sending' && <Spinner />}
                  {request === 'sending' ? t.joining : t.request}
                </Button>
                {request === 'failed' && <FormError id={`${id}-rf`}>{t.failed}</FormError>}
              </form>
            )}
          </div>
        </div>
        {p.note && <p className="mt-5 text-center text-[13px] text-muted-foreground">{p.note}</p>}
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */

const VIEWS: Record<WaitlistVariant, (p: ViewProps) => React.ReactNode> = {
  'referral-queue': ReferralQueue,
  'split-form': SplitForm,
  crowd: Crowd,
  'invite-code': InviteCode,
};

export function Waitlist({ variant = 'referral-queue', labels, ...rest }: WaitlistProps) {
  const t = useTexts(TEXTS, labels);
  const View = VIEWS[variant] ?? ReferralQueue;
  return <View {...rest} variant={variant} t={t} />;
}

Frequent questions

How do I install Waitlist?

With aiw add @ai-website/waitlist. The command puts components/ui/waitlist.tsx into your project, the code is yours from then on. It brings along motion, button, icon, number-ticker and section. It needs no further npm packages.

Which variants does Waitlist have?

4 variants, Place in line with invites, Perks and short form, Crowd around one field and Invite code or request. You pick one with the variant prop, for example variant="split-form".

Can I translate the texts of Waitlist?

Yes. Waitlist carries 42 texts of its own in English and German, such as “Email address”, “you@company.com” and “Your name”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Waitlist?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Waitlist with aiw_components and install it with aiw_add @ai-website/waitlist. Name the variant in your prompt, for example variant="referral-queue", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard