Skip to content
UI Kit
Accent color

Registration

Signing up for an event, a course or a tournament, for one person or many. A block per ticket, dates with seats left and a waitlist, a team with a captain, ticket types with proof and workshops that warn about a clash.

One of 3 components in Applications and sign-ups

4 variants

Variants

Several attendees

The ticket count makes one block per person, new ones rise in. The first can take the buyer details with one tick, food is chosen per person, the total counts along at the bottom.

Open in Studio

Dates with seats

Each date shows the seats left on a bar that fills to what is taken, few seats are set in bold. A full date stays choosable, a note opens and the button turns into the waitlist.

Open in Studio

Team

Team name, category and a captain in a highlighted box, then members with shirt sizes. Dots count up to the minimum and turn into "ready to start", a new member line takes the focus.

Open in Studio

Tickets and workshops

Ticket types as cards, a reduced ticket opens an upload for its proof. Workshops sit in their time slots, choosing two at the same time marks the slot and says which to drop.

Open in Studio

Details

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

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/registration.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputselectnumber-inputcheckboxfile-upload
npm packages
None
Languages
English and German, 46 texts of its own
In collections
Event

Props

The RegistrationProps interface from components/ui/registration.tsx, with the comments from the code.

  • variantoptional
    RegistrationVariant

    attendeessessionsteamtickets

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • eventoptional
    { name: string; date?: string; place?: string; icon?: IconName }

    The event in a short card: name, date, place

  • priceoptional
    number

    attendees: price per ticket, most tickets, diet choices

  • maxTicketsoptional
    number
  • dietsoptional
    string[]
  • sessionsoptional
    RegistrationSession[]

    sessions

  • minMembersoptional
    number

    team: members needed and allowed, categories, shirt sizes

  • maxMembersoptional
    number
  • categoriesoptional
    string[]
  • sizesoptional
    string[]
  • ticketsoptional
    RegistrationTicket[]

    tickets

  • workshopsoptional
    RegistrationWorkshop[]
  • currencyoptional
    string
  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { title: string; text?: string }
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Built-in texts

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

Show all 46 texts
ticketsEnglish TicketsGerman Tickets
nameEnglish NameGerman Name
firstNameEnglish First nameGerman Vorname
lastNameEnglish Last nameGerman Nachname
nameNeededEnglish Please add a name, it goes on the ticket.German Bitte gib einen Namen an, er steht auf dem Ticket.
emailEnglish EmailGerman E-Mail
emailNeededEnglish This address looks incomplete. Please use a form like name@example.com so the ticket reaches you.German Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Ticket dich erreicht.
attendeeEnglish Participant {n}German Person {n}
sameEnglish This is meGerman Das bin ich
dietEnglish FoodGerman Essen
totalEnglish TotalGerman Gesamt
fromPriceEnglish from {p}German ab {p}
sessionEnglish Choose a dateGerman Wähle einen Termin
sessionNeededEnglish Please choose a date, so we can hold a seat for you.German Bitte wähle einen Termin, damit wir dir einen Platz freihalten.
leftEnglish {n} of {total} seats leftGerman Noch {n} von {total} Plätzen
fullEnglish Full, waitlist openGerman Voll, Warteliste offen
waitlistNoteEnglish You are on the waitlist for this date. If a seat frees up, we write to you at once.German Du stehst auf der Warteliste für diesen Termin. Wird ein Platz frei, schreiben wir dir sofort.
teamNameEnglish Team nameGerman Teamname
teamNeededEnglish Please name your team, it goes on the start list.German Bitte gib eurem Team einen Namen, er steht auf der Startliste.
categoryEnglish CategoryGerman Kategorie
categoryNeededEnglish Please choose a category, it decides your start time.German Bitte wähle eine Kategorie, sie bestimmt eure Startzeit.
captainEnglish CaptainGerman Kapitänin oder Kapitän
membersEnglish Team membersGerman Teammitglieder
memberEnglish Member {n}German Mitglied {n}
addMemberEnglish Add a memberGerman Mitglied hinzufügen
removeMemberEnglish Remove member {n}German Mitglied {n} entfernen
membersCountEnglish {n} of {min} neededGerman {n} von {min} nötig
membersReadyEnglish {n} members, ready to startGerman {n} Mitglieder, startklar
membersNeededEnglish Please add at least {n} members, the rules ask for it.German Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln.
sizeEnglish Shirt sizeGerman Shirtgröße
ticketEnglish TicketGerman Ticket
ticketNeededEnglish Please choose a ticket.German Bitte wähle ein Ticket.
proofEnglish ProofGerman Nachweis
proofNeededEnglish Please add your proof for the reduced ticket, a photo is fine.German Bitte füge den Nachweis für das ermäßigte Ticket hinzu, ein Foto reicht.
workshopsEnglish WorkshopsGerman Workshops
workshopsHintEnglish One per slot, workshops in the same slot run at the same timeGerman Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig
clashEnglish {a} and {b} run at the same time. Please keep one of them.German {a} und {b} laufen gleichzeitig. Bitte behalte einen davon.
slotEnglish Slot {s}German Um {s} Uhr
consentEnglish I agree that my details are used to run this event.German Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden.
consentNeededEnglish Please agree so we are allowed to register you.German Bitte stimme zu, damit wir dich anmelden dürfen.
sendEnglish RegisterGerman Anmelden
sendWaitEnglish Join the waitlistGerman Auf die Warteliste
doneTitleEnglish You are registeredGerman Du bist angemeldet
doneTextEnglish Your confirmation and tickets are in your inbox.German Bestätigung und Tickets liegen in deinem Postfach.
waitTitleEnglish You are on the waitlistGerman Du stehst auf der Warteliste
againEnglish Register someone elseGerman Weitere Person anmelden

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the role alert.
  • 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.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 761 lines
components/ui/registration.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { NumberInput } from '@/components/ui/number-input';
import { Checkbox } from '@/components/ui/checkbox';
import { FileUpload } from '@/components/ui/file-upload';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DonePanel, ErrorSummary, FlowHead, isEmail, Unfold, useFocusFirstError, type DoneStep, summaryOf, useFormState } from '@/components/ui/form-flow';

/*
 * Signing up for an event, a course or a tournament, for one person or many.
 *
 *   attendees  one block per ticket, the first can take the buyer's details
 *   sessions   dates with seats left on a bar, full ones turn into a waitlist
 *   team       a team with a captain and members, sizes as chips, a counter to the minimum
 *   tickets    ticket types with proof for reduced ones, workshops that warn about a clash
 *
 * Every variant checks before sending, lists what is missing above the form
 * and focuses the first open field. Sends everything to `onSubmit`.
 */

export type RegistrationVariant = 'attendees' | 'sessions' | 'team' | 'tickets';

export interface RegistrationSession {
  value: string;
  /** e.g. "Tue 4 Nov, 18:00 to 20:30" */
  label: string;
  detail?: string;
  seats: number;
  taken: number;
}

export interface RegistrationTicket {
  value: string;
  name: string;
  price: number;
  text?: string;
  /** Asks for a proof, e.g. a student card */
  proof?: string;
}

export interface RegistrationWorkshop {
  value: string;
  title: string;
  /** Workshops with the same slot clash */
  slot: string;
  host?: string;
}

export interface RegistrationProps {
  variant?: RegistrationVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** The event in a short card: name, date, place */
  event?: { name: string; date?: string; place?: string; icon?: IconName };
  /** attendees: price per ticket, most tickets, diet choices */
  price?: number;
  maxTickets?: number;
  diets?: string[];
  /** sessions */
  sessions?: RegistrationSession[];
  /** team: members needed and allowed, categories, shirt sizes */
  minMembers?: number;
  maxMembers?: number;
  categories?: string[];
  sizes?: string[];
  /** tickets */
  tickets?: RegistrationTicket[];
  workshops?: RegistrationWorkshop[];
  currency?: string;
  steps?: DoneStep[];
  submitLabel?: string;
  done?: { title: string; text?: string };
  onSubmit?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    tickets: 'Tickets',
    name: 'Name',
    firstName: 'First name',
    lastName: 'Last name',
    nameNeeded: 'Please add a name, it goes on the ticket.',
    email: 'Email',
    emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so the ticket reaches you.',
    attendee: 'Participant {n}',
    same: 'This is me',
    diet: 'Food',
    total: 'Total',
    fromPrice: 'from {p}',
    session: 'Choose a date',
    sessionNeeded: 'Please choose a date, so we can hold a seat for you.',
    left: '{n} of {total} seats left',
    full: 'Full, waitlist open',
    waitlistNote: 'You are on the waitlist for this date. If a seat frees up, we write to you at once.',
    teamName: 'Team name',
    teamNeeded: 'Please name your team, it goes on the start list.',
    category: 'Category',
    categoryNeeded: 'Please choose a category, it decides your start time.',
    captain: 'Captain',
    members: 'Team members',
    member: 'Member {n}',
    addMember: 'Add a member',
    removeMember: 'Remove member {n}',
    membersCount: '{n} of {min} needed',
    membersReady: '{n} members, ready to start',
    membersNeeded: 'Please add at least {n} members, the rules ask for it.',
    size: 'Shirt size',
    ticket: 'Ticket',
    ticketNeeded: 'Please choose a ticket.',
    proof: 'Proof',
    proofNeeded: 'Please add your proof for the reduced ticket, a photo is fine.',
    workshops: 'Workshops',
    workshopsHint: 'One per slot, workshops in the same slot run at the same time',
    clash: '{a} and {b} run at the same time. Please keep one of them.',
    slot: 'Slot {s}',
    consent: 'I agree that my details are used to run this event.',
    consentNeeded: 'Please agree so we are allowed to register you.',
    send: 'Register',
    sendWait: 'Join the waitlist',
    doneTitle: 'You are registered',
    doneText: 'Your confirmation and tickets are in your inbox.',
    waitTitle: 'You are on the waitlist',
    again: 'Register someone else',
  },
  de: {
    tickets: 'Tickets',
    name: 'Name',
    firstName: 'Vorname',
    lastName: 'Nachname',
    nameNeeded: 'Bitte gib einen Namen an, er steht auf dem Ticket.',
    email: 'E-Mail',
    emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit das Ticket dich erreicht.',
    attendee: 'Person {n}',
    same: 'Das bin ich',
    diet: 'Essen',
    total: 'Gesamt',
    fromPrice: 'ab {p}',
    session: 'Wähle einen Termin',
    sessionNeeded: 'Bitte wähle einen Termin, damit wir dir einen Platz freihalten.',
    left: 'Noch {n} von {total} Plätzen',
    full: 'Voll, Warteliste offen',
    waitlistNote: 'Du stehst auf der Warteliste für diesen Termin. Wird ein Platz frei, schreiben wir dir sofort.',
    teamName: 'Teamname',
    teamNeeded: 'Bitte gib eurem Team einen Namen, er steht auf der Startliste.',
    category: 'Kategorie',
    categoryNeeded: 'Bitte wähle eine Kategorie, sie bestimmt eure Startzeit.',
    captain: 'Kapitänin oder Kapitän',
    members: 'Teammitglieder',
    member: 'Mitglied {n}',
    addMember: 'Mitglied hinzufügen',
    removeMember: 'Mitglied {n} entfernen',
    membersCount: '{n} von {min} nötig',
    membersReady: '{n} Mitglieder, startklar',
    membersNeeded: 'Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln.',
    size: 'Shirtgröße',
    ticket: 'Ticket',
    ticketNeeded: 'Bitte wähle ein Ticket.',
    proof: 'Nachweis',
    proofNeeded: 'Bitte füge den Nachweis für das ermäßigte Ticket hinzu, ein Foto reicht.',
    workshops: 'Workshops',
    workshopsHint: 'Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig',
    clash: '{a} und {b} laufen gleichzeitig. Bitte behalte einen davon.',
    slot: 'Um {s} Uhr',
    consent: 'Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden.',
    consentNeeded: 'Bitte stimme zu, damit wir dich anmelden dürfen.',
    send: 'Anmelden',
    sendWait: 'Auf die Warteliste',
    doneTitle: 'Du bist angemeldet',
    doneText: 'Bestätigung und Tickets liegen in deinem Postfach.',
    waitTitle: 'Du stehst auf der Warteliste',
    again: 'Weitere Person anmelden',
  },
};

type Texts = (typeof TEXTS)['en'];

const money = (n: number, c: string, locale: 'en' | 'de') => (locale === 'de' ? `${n.toLocaleString('de-DE')} ${c}` : `${c}${n.toLocaleString('en-US')}`);

function EventCard({ event }: { event: NonNullable<RegistrationProps['event']> }) {
  return (
    <div className="flex items-center gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
      <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
        <Icon name={event.icon ?? 'ticket'} size={20} />
      </span>
      <span className="min-w-0">
        <span className="block text-[16px] font-semibold leading-snug text-foreground">{event.name}</span>
        <span className="block text-[13px] text-muted-foreground">{[event.date, event.place].filter(Boolean).join(' · ')}</span>
      </span>
    </div>
  );
}

function Done({ p, t, onAgain, title, text, className }: { p: RegistrationProps; t: Texts; onAgain: () => void; title?: string; text?: string; className?: string }) {
  return (
    <div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
      <DonePanel
        title={title ?? p.done?.title ?? t.doneTitle}
        text={text ?? p.done?.text ?? t.doneText}
        steps={p.steps}
        actions={
          <Button variant="outline" iconLeft="user-plus" onClick={onAgain}>
            {t.again}
          </Button>
        }
      />
    </div>
  );
}

function Consent({ id, t, f, value, set }: { id: string; t: Texts; f: ReturnType<typeof useFormState>; value: boolean; set: (v: boolean) => void }) {
  return <Checkbox id={`${id}-consent`} label={t.consent} checked={value} onChange={(e) => (set(e.target.checked), f.clear('consent'))} error={f.errors.consent} />;
}

/* ------------------------------------------------------------------ */
/* attendees                                                           */
/* ------------------------------------------------------------------ */

interface Person {
  first: string;
  last: string;
  email: string;
  diet: string;
}
const blank = (diet = ''): Person => ({ first: '', last: '', email: '', diet });

function Attendees({ p, t }: { p: RegistrationProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const f = useFormState();
  const diets = p.diets ?? [];
  const [count, setCount] = useState(2);
  const [people, setPeople] = useState<Person[]>([blank(diets[0]), blank(diets[0])]);
  const [buyer, setBuyer] = useState({ name: '', email: '' });
  const [sameAsBuyer, setSame] = useState(false);
  const [consent, setConsent] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const c = p.currency ?? '€';
  const setCountAndPeople = (n: number) => {
    setCount(n);
    setPeople((x) => (n > x.length ? [...x, ...Array.from({ length: n - x.length }, () => blank(diets[0]))] : x.slice(0, n)));
  };
  const setPerson = (i: number, patch: Partial<Person>) => setPeople((x) => x.map((y, j) => (j === i ? { ...y, ...patch } : y)));
  const shown = people.map((x, i) => (i === 0 && sameAsBuyer ? { ...x, first: buyer.name.split(' ')[0] ?? '', last: buyer.name.split(' ').slice(1).join(' '), email: buyer.email } : x));
  const summary = summaryOf(f.errors, [
    ['buyer', `${id}-buyer`, t.name],
    ['buyerEmail', `${id}-buyer-email`, t.email],
    ...people.map((_, i) => [`p${i}`, `${id}-p${i}-first`, t.attendee.replace('{n}', String(i + 1))] as [string, string, string]),
    ['consent', `${id}-consent`, t.consent],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setPeople([blank(diets[0]), blank(diets[0])]), setCount(2), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[52rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!buyer.name.trim()) found.buyer = t.nameNeeded;
          if (!isEmail(buyer.email)) found.buyerEmail = t.emailNeeded;
          shown.forEach((x, i) => {
            if (!x.first.trim()) found[`p${i}`] = t.nameNeeded;
          });
          if (!consent) found.consent = t.consentNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ buyer, attendees: shown, total: count * (p.price ?? 0) });
          f.setDone(true);
        }}
      >
        {p.event && <EventCard event={p.event} />}
        <div className="grid gap-7 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-[minmax(0,1fr)_14rem] sm:items-end">
            <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
              <Input id={`${id}-buyer`} label={t.name} required autoComplete="name" value={buyer.name} onChange={(e) => (setBuyer((b) => ({ ...b, name: e.target.value })), f.clear('buyer'))} error={f.errors.buyer} />
              <Input id={`${id}-buyer-email`} type="email" label={t.email} required autoComplete="email" value={buyer.email} onChange={(e) => (setBuyer((b) => ({ ...b, email: e.target.value })), f.clear('buyerEmail'))} error={f.errors.buyerEmail} />
            </div>
            <NumberInput label={t.tickets} variant="stepper" min={1} max={p.maxTickets ?? 8} value={count} onValueChange={setCountAndPeople} />
          </div>
          {/* One block per ticket, new ones rise in, removed ones go */}
          <Stagger step={80} as="ol" className="grid gap-3">
            {people.map((x, i) => {
              const linked = i === 0 && sameAsBuyer;
              const v = shown[i];
              return (
                <li key={i} className="kit-rise rounded-xl border border-border bg-surface p-4 sm:p-5">
                  <div className="flex flex-wrap items-center justify-between gap-3">
                    <p className="flex items-center gap-2.5 text-[14px] font-semibold text-foreground">
                      <span className="grid size-7 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[12px] tabular-nums text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{i + 1}</span>
                      {t.attendee.replace('{n}', String(i + 1))}
                    </p>
                    {i === 0 && <Checkbox label={t.same} checked={sameAsBuyer} onChange={(e) => (setSame(e.target.checked), f.clear('p0'))} />}
                  </div>
                  <div className="mt-4 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-[1fr_1fr_1.2fr_10rem]">
                    <Input id={`${id}-p${i}-first`} label={t.firstName} required autoComplete="off" disabled={linked} value={v.first} onChange={(e) => (setPerson(i, { first: e.target.value }), f.clear(`p${i}`))} error={f.errors[`p${i}`]} />
                    <Input label={t.lastName} optional autoComplete="off" disabled={linked} value={v.last} onChange={(e) => setPerson(i, { last: e.target.value })} />
                    <Input type="email" label={t.email} optional autoComplete="off" disabled={linked} value={v.email} onChange={(e) => setPerson(i, { email: e.target.value })} className="max-sm:col-span-2" />
                    {diets.length > 0 && <NativeSelect label={t.diet} options={diets.map((d) => ({ value: d, label: d }))} value={x.diet} onChange={(e) => setPerson(i, { diet: e.target.value })} className="max-sm:col-span-2" />}
                  </div>
                </li>
              );
            })}
          </Stagger>
          <Consent id={id} t={t} f={f} value={consent} set={setConsent} />
        </div>
        <div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
          <p className="flex items-baseline gap-2">
            <span className="text-[14px] text-muted-foreground">{t.total}</span>
            <span key={count} className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
              {money(count * (p.price ?? 0), c, locale)}
            </span>
            <span className="text-[13px] tabular-nums text-muted-foreground">
              {count} × {money(p.price ?? 0, c, locale)}
            </span>
          </p>
          <Button type="submit" icon="arrow-right" className="max-sm:w-full">
            {p.submitLabel ?? t.send}
          </Button>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* sessions                                                            */
/* ------------------------------------------------------------------ */

function Sessions({ p, t }: { p: RegistrationProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const sessions = p.sessions ?? [];
  const [pick, setPick] = useState('');
  const [v, setV] = useState({ name: '', email: '' });
  const [consent, setConsent] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const chosen = sessions.find((s) => s.value === pick);
  const wait = chosen ? chosen.taken >= chosen.seats : false;
  const summary = summaryOf(f.errors, [
    ['session', `${id}-s0`, t.session],
    ['name', `${id}-name`, t.name],
    ['email', `${id}-email`, t.email],
    ['consent', `${id}-consent`, t.consent],
  ]);
  if (f.done) return <Done p={p} t={t} title={wait ? t.waitTitle : undefined} text={wait ? t.waitlistNote : undefined} onAgain={() => (setPick(''), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[48rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!pick) found.session = t.sessionNeeded;
          if (!v.name.trim()) found.name = t.nameNeeded;
          if (!isEmail(v.email)) found.email = t.emailNeeded;
          if (!consent) found.consent = t.consentNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ session: pick, waitlist: wait, ...v });
          f.setDone(true);
        }}
      >
        {p.event && <EventCard event={p.event} />}
        <div className="grid gap-7 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <fieldset className="min-w-0" aria-describedby={f.errors.session ? `${id}-se` : undefined}>
            <legend className="text-[15px] font-semibold text-foreground">{t.session}</legend>
            <Stagger step={80} as="div" className="mt-4 grid gap-2.5">
              {sessions.map((s, i) => {
                const left = Math.max(0, s.seats - s.taken);
                const full = left === 0;
                const share = Math.min(1, s.taken / Math.max(1, s.seats));
                const on = pick === s.value;
                return (
                  <label key={s.value} htmlFor={`${id}-s${i}`} className={cn('group/s relative grid cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 rounded-xl border bg-card p-4 transition-[border-color,box-shadow,background-color] duration-300 sm:grid-cols-[auto_minmax(0,1fr)_12rem]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border))]' : f.errors.session ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
                    <input id={`${id}-s${i}`} type="radio" name={`${id}-session`} value={s.value} checked={on} onChange={() => (setPick(s.value), f.clear('session'))} aria-invalid={f.errors.session ? true : undefined} aria-describedby={`${id}-s${i}-seats`} className="sr-only" />
                    <span aria-hidden className={cn('grid size-5 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                      <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0">
                      <span className="block text-[15px] font-medium text-foreground">{s.label}</span>
                      {s.detail && <span className="block text-[13px] text-muted-foreground">{s.detail}</span>}
                    </span>
                    {/* Seats as a bar that fills to what is taken */}
                    <span className="col-span-2 sm:col-span-1">
                      <span id={`${id}-s${i}-seats`} className={cn('flex items-center gap-1.5 text-[12.5px] tabular-nums', full ? 'font-medium text-warning-foreground' : left <= 3 ? 'font-medium text-foreground' : 'text-muted-foreground')}>
                        {full && <Icon name="hourglass" size={13} />}
                        {full ? t.full : t.left.replace('{n}', String(left)).replace('{total}', String(s.seats))}
                      </span>
                      <span aria-hidden className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)]">
                        <span className={cn('block h-full origin-left rounded-full transition-transform duration-1000 ease-[var(--ease-kit)]', full ? 'bg-warning' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 3)})` }} />
                      </span>
                    </span>
                  </label>
                );
              })}
            </Stagger>
            {f.errors.session && (
              <p id={`${id}-se`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {f.errors.session}
              </p>
            )}
            {/* Closed, the note takes no room, so the next block keeps its usual distance */}
            <Unfold open={wait}>
            <p className="mt-5 flex items-start gap-3 rounded-xl bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] p-4 text-[14px] leading-snug text-warning-foreground">
              <Icon name="info" size={18} className="mt-px shrink-0" />
              {t.waitlistNote}
            </p>
            </Unfold>
          </fieldset>
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
            <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
          </div>
          <Consent id={id} t={t} f={f} value={consent} set={setConsent} />
          <div className="flex justify-end border-t border-border pt-6">
            <Button type="submit" variant={wait ? 'outline' : 'primary'} icon={wait ? 'hourglass' : 'arrow-right'} className="max-sm:w-full">
              {wait ? t.sendWait : (p.submitLabel ?? t.send)}
            </Button>
          </div>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* team                                                                */
/* ------------------------------------------------------------------ */

interface Member {
  name: string;
  size: string;
}

function Team({ p, t }: { p: RegistrationProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const min = p.minMembers ?? 4;
  const max = p.maxMembers ?? 8;
  const sizes = p.sizes ?? ['S', 'M', 'L', 'XL'];
  const [team, setTeam] = useState('');
  const [category, setCategory] = useState<string[]>([]);
  const [captain, setCaptain] = useState({ name: '', email: '', size: sizes[1] ?? sizes[0] });
  const [members, setMembers] = useState<Member[]>(Array.from({ length: min - 1 }, () => ({ name: '', size: sizes[1] ?? sizes[0] })));
  const [consent, setConsent] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  const added = useRef(false);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  useEffect(() => {
    if (!added.current) return;
    added.current = false;
    document.getElementById(`${id}-m${members.length - 1}`)?.focus();
  }, [members.length, id]);
  const filled = 1 + members.filter((m) => m.name.trim()).length;
  const ready = filled >= min;
  const summary = summaryOf(f.errors, [
    ['team', `${id}-team`, t.teamName],
    ['category', `${id}-cat`, t.category],
    ['captain', `${id}-captain`, t.captain],
    ['email', `${id}-email`, t.email],
    ['members', `${id}-m0`, t.members],
    ['consent', `${id}-consent`, t.consent],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setTeam(''), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[52rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!team.trim()) found.team = t.teamNeeded;
          if (!category.length) found.category = t.categoryNeeded;
          if (!captain.name.trim()) found.captain = t.nameNeeded;
          if (!isEmail(captain.email)) found.email = t.emailNeeded;
          if (!ready) found.members = t.membersNeeded.replace('{n}', String(min));
          if (!consent) found.consent = t.consentNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ team, category: category[0], captain, members: members.filter((m) => m.name.trim()) });
          f.setDone(true);
        }}
      >
        {p.event && <EventCard event={p.event} />}
        <div className="grid gap-7 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <Input id={`${id}-team`} label={t.teamName} required value={team} onChange={(e) => (setTeam(e.target.value), f.clear('team'))} error={f.errors.team} size="lg" />
            <ChipGroup id={`${id}-cat`} legend={t.category} required options={(p.categories ?? []).map((x) => ({ value: x, label: x }))} value={category} onChange={(v) => (setCategory(v), f.clear('category'))} error={f.errors.category} />
          </div>
          <fieldset className="min-w-0 rounded-xl border border-border bg-surface p-4 sm:p-5">
            <legend className="sr-only">{t.captain}</legend>
            <p aria-hidden className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
              <Icon name="crown" size={16} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
              {t.captain}
            </p>
            <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_8rem]">
              <Input id={`${id}-captain`} label={t.name} required autoComplete="name" value={captain.name} onChange={(e) => (setCaptain((c) => ({ ...c, name: e.target.value })), f.clear('captain'))} error={f.errors.captain} />
              <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={captain.email} onChange={(e) => (setCaptain((c) => ({ ...c, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
              <NativeSelect label={t.size} options={sizes.map((s) => ({ value: s, label: s }))} value={captain.size} onChange={(e) => setCaptain((c) => ({ ...c, size: e.target.value }))} />
            </div>
          </fieldset>
          <fieldset className="min-w-0">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <legend className="text-[15px] font-semibold text-foreground">{t.members}</legend>
              {/* Dots to the minimum, they fill as names come in */}
              <p aria-live="polite" className={cn('flex items-center gap-2 text-[13px] tabular-nums', ready ? 'font-medium text-success-foreground' : 'text-muted-foreground')}>
                <span aria-hidden className="flex gap-1">
                  {Array.from({ length: min }, (_, i) => (
                    <span key={i} className={cn('size-2 rounded-full transition-[background-color,scale] duration-300', i < filled ? 'scale-100 bg-primary' : 'scale-90 bg-[color-mix(in_oklab,var(--foreground)_15%,transparent)]')} />
                  ))}
                </span>
                {ready ? t.membersReady.replace('{n}', String(filled)) : t.membersCount.replace('{n}', String(filled)).replace('{min}', String(min))}
              </p>
            </div>
            <ol className="mt-4 grid gap-2.5">
              {members.map((m, i) => (
                <li key={i} className="kit-rise grid grid-cols-[minmax(0,1fr)_5.5rem_2.75rem] items-center gap-2 sm:grid-cols-[2rem_minmax(0,1fr)_8rem_2.75rem] sm:gap-3">
                  <span aria-hidden className="hidden text-[13px] font-semibold tabular-nums text-subtle-foreground sm:block">
                    {i + 2}
                  </span>
                  <Input id={`${id}-m${i}`} aria-label={t.member.replace('{n}', String(i + 2))} placeholder={t.member.replace('{n}', String(i + 2))} value={m.name} invalid={Boolean(f.errors.members) && !m.name.trim()} aria-describedby={f.errors.members ? `${id}-me` : undefined} onChange={(e) => (setMembers((x) => x.map((y, j) => (j === i ? { ...y, name: e.target.value } : y))), f.clear('members'))} />
                  <NativeSelect aria-label={`${t.member.replace('{n}', String(i + 2))}, ${t.size}`} options={sizes.map((s) => ({ value: s, label: s }))} value={m.size} onChange={(e) => setMembers((x) => x.map((y, j) => (j === i ? { ...y, size: e.target.value } : y)))} />
                  <button type="button" aria-label={t.removeMember.replace('{n}', String(i + 2))} onClick={() => setMembers((x) => x.filter((_, j) => j !== i))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
                    <Icon name="trash" size={17} />
                  </button>
                </li>
              ))}
            </ol>
            {f.errors.members && (
              <p id={`${id}-me`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {f.errors.members}
              </p>
            )}
            {members.length + 1 < max && (
              <Button
                type="button"
                variant="outline"
                size="sm"
                iconLeft="user-plus"
                className="mt-3"
                onClick={() => {
                  added.current = true;
                  setMembers((x) => [...x, { name: '', size: sizes[1] ?? sizes[0] }]);
                }}
              >
                {t.addMember}
              </Button>
            )}
          </fieldset>
          <Consent id={id} t={t} f={f} value={consent} set={setConsent} />
          <div className="flex justify-end border-t border-border pt-6">
            <Button type="submit" icon="arrow-right" className="max-sm:w-full">
              {p.submitLabel ?? t.send}
            </Button>
          </div>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* tickets                                                             */
/* ------------------------------------------------------------------ */

function Tickets({ p, t }: { p: RegistrationProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const f = useFormState();
  const c = p.currency ?? '€';
  const tickets = p.tickets ?? [];
  const workshops = p.workshops ?? [];
  const [ticket, setTicket] = useState('');
  const [proof, setProof] = useState<File[]>([]);
  const [picked, setPicked] = useState<string[]>([]);
  const [v, setV] = useState({ name: '', email: '' });
  const [consent, setConsent] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const chosen = tickets.find((x) => x.value === ticket);
  const slots = [...new Set(workshops.map((w) => w.slot))];
  // Two workshops in the same slot clash
  const clashes = slots
    .map((s) => workshops.filter((w) => w.slot === s && picked.includes(w.value)))
    .filter((list) => list.length > 1);
  const summary = summaryOf(f.errors, [
    ['ticket', `${id}-t0`, t.ticket],
    ['proof', `${id}-proof`, t.proof],
    ['clash', `${id}-ws`, t.workshops],
    ['name', `${id}-name`, t.name],
    ['email', `${id}-email`, t.email],
    ['consent', `${id}-consent`, t.consent],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setTicket(''), setPicked([]), setConsent(false), f.setDone(false))} className="max-w-[46rem]" />;
  return (
    <Reveal effect="rise" className="mx-auto max-w-[56rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!ticket) found.ticket = t.ticketNeeded;
          if (chosen?.proof && !proof.length) found.proof = t.proofNeeded;
          if (clashes.length) found.clash = t.clash.replace('{a}', clashes[0][0].title).replace('{b}', clashes[0][1].title);
          if (!v.name.trim()) found.name = t.nameNeeded;
          if (!isEmail(v.email)) found.email = t.emailNeeded;
          if (!consent) found.consent = t.consentNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ ticket, workshops: picked, ...v, proof: proof[0] });
          f.setDone(true);
        }}
      >
        {p.event && <EventCard event={p.event} />}
        <div className="grid gap-8 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <fieldset className="min-w-0" aria-describedby={f.errors.ticket ? `${id}-te` : undefined}>
            <legend className="text-[15px] font-semibold text-foreground">{t.ticket}</legend>
            <Stagger step={80} as="div" className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
              {tickets.map((x, i) => {
                const on = ticket === x.value;
                return (
                  <label key={x.value} htmlFor={`${id}-t${i}`} className={cn('flex h-full cursor-pointer flex-col rounded-xl border bg-card p-4 transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_55%,var(--border))]' : f.errors.ticket ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border hover:border-border-strong', 'has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]')}>
                    <input id={`${id}-t${i}`} type="radio" name={`${id}-ticket`} value={x.value} checked={on} onChange={() => (setTicket(x.value), f.clear('ticket'), f.clear('proof'))} className="sr-only" />
                    <span className="flex items-center justify-between gap-3">
                      <span className="text-[15px] font-semibold text-foreground">{x.name}</span>
                      <span aria-hidden className={cn('grid size-5 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                        <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
                      </span>
                    </span>
                    <span className="mt-1.5 text-[20px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{money(x.price, c, locale)}</span>
                    {x.text && <span className="mt-1 text-[13px] leading-snug text-muted-foreground">{x.text}</span>}
                  </label>
                );
              })}
            </Stagger>
            {f.errors.ticket && (
              <p id={`${id}-te`} className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {f.errors.ticket}
              </p>
            )}
            {/* A reduced ticket asks for its proof, closed it takes no room */}
            <Unfold open={Boolean(chosen?.proof)}>
            <div id={`${id}-proof`} className="pt-6">
              <FileUpload variant="compact" label={<>{t.proof}<span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">*</span></>} hint={chosen?.proof} accept=".pdf,image/*" maxSize={10e6} maxFiles={1} multiple={false} items={proof.map((x, i) => ({ id: `${i}`, name: x.name, size: x.size, status: 'done' as const }))} onFiles={(x) => (setProof(x.slice(0, 1)), f.clear('proof'))} onRemove={() => setProof([])} error={f.errors.proof} />
            </div>
            </Unfold>
          </fieldset>
          {workshops.length > 0 && (
            <fieldset id={`${id}-ws`} className="min-w-0" tabIndex={-1}>
              <legend className="text-[15px] font-semibold text-foreground">{t.workshops}</legend>
              <p className="mt-1 text-[13px] text-muted-foreground">{t.workshopsHint}</p>
              <div className="mt-4 grid grid-cols-1 gap-5 md:grid-cols-2">
                {slots.map((s) => {
                  const clash = clashes.some((list) => list[0].slot === s);
                  return (
                    <div key={s} className={cn('rounded-xl border p-4 transition-colors duration-300', clash ? 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_4%,var(--card))]' : 'border-border bg-surface')}>
                      <p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
                        <Icon name="clock" size={14} />
                        {t.slot.replace('{s}', s)}
                      </p>
                      <div className="mt-3 grid gap-2.5">
                        {workshops
                          .filter((w) => w.slot === s)
                          .map((w) => (
                            <Checkbox
                              key={w.value}
                              label={w.title}
                              description={w.host}
                              checked={picked.includes(w.value)}
                              onChange={(e) => {
                                setPicked((x) => (e.target.checked ? [...x, w.value] : x.filter((y) => y !== w.value)));
                                f.clear('clash');
                              }}
                            />
                          ))}
                      </div>
                      {clash && (
                        <p role="alert" className="kit-rise mt-3 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                          <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                          {(() => {
                            const list = clashes.find((l) => l[0].slot === s) ?? [];
                            return t.clash.replace('{a}', list[0]?.title ?? '').replace('{b}', list[1]?.title ?? '');
                          })()}
                        </p>
                      )}
                    </div>
                  );
                })}
              </div>
            </fieldset>
          )}
          <div className="grid grid-cols-1 gap-5 border-t border-border pt-7 sm:grid-cols-2">
            <Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
            <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
          </div>
          <Consent id={id} t={t} f={f} value={consent} set={setConsent} />
        </div>
        <div className="flex flex-col gap-4 border-t border-border bg-surface px-6 py-5 sm:flex-row sm:items-center sm:justify-between sm:px-8">
          <p className="flex items-baseline gap-2">
            <span className="text-[14px] text-muted-foreground">{t.total}</span>
            <span key={ticket} className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
              {chosen ? money(chosen.price, c, locale) : t.fromPrice.replace('{p}', money(Math.min(...tickets.map((x) => x.price)), c, locale))}
            </span>
          </p>
          <Button type="submit" icon="arrow-right" className="max-sm:w-full">
            {p.submitLabel ?? t.send}
          </Button>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * Sign-up for events, courses and tournaments.
 *   <Registration variant="attendees" event={{ name, date, place }} price={45} diets={['Any', 'Vegetarian']} />
 */
export function Registration(props: RegistrationProps) {
  const { variant = 'attendees', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
      {variant === 'attendees' && <Attendees p={props} t={t} />}
      {variant === 'sessions' && <Sessions p={props} t={t} />}
      {variant === 'team' && <Team p={props} t={t} />}
      {variant === 'tickets' && <Tickets p={props} t={t} />}
    </Section>
  );
}

Frequent questions

How do I install Registration?

With aiw add @ai-website/registration. The command puts components/ui/registration.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, select, number-input, checkbox and file-upload. It needs no further npm packages.

Which variants does Registration have?

4 variants, Several attendees, Dates with seats, Team and Tickets and workshops. You pick one with the variant prop, for example variant="sessions".

Can I translate the texts of Registration?

Yes. Registration carries 46 texts of its own in English and German, such as “Tickets”, “Name” and “First name”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Registration?

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

Can I adjust Registration without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching Registration code to copy.

Often searched

Jump to

Copied to the clipboard