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
Variants
Installv1.0.0
aiw add @ai-website/registrationBrings along utils, kit, button, icon, reveal, section, motion, form-flow, input, select, number-input, checkbox, file-upload
Usage
<Registration variant="tickets" tickets={[{ value, name, price, proof }]} workshops={[{ value, title, slot, host }]} currency="€" />Details
What Registration brings along, read from the registry and the source.
- Category
- Applications and sign-ups
- Area
- Content
- 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
- Studio
- All 4 variants editable
- In collections
- Event
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RegistrationProps interface from components/ui/registration.tsx, with the comments from the code.
variantoptionalRegistrationVariantattendeessessionsteamtickets
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeeventoptional{ name: string; date?: string; place?: string; icon?: IconName }The event in a short card: name, date, place
priceoptionalnumberattendees: price per ticket, most tickets, diet choices
maxTicketsoptionalnumberdietsoptionalstring[]sessionsoptionalRegistrationSession[]sessions
minMembersoptionalnumberteam: members needed and allowed, categories, shirt sizes
maxMembersoptionalnumbercategoriesoptionalstring[]sizesoptionalstring[]ticketsoptionalRegistrationTicket[]tickets
workshopsoptionalRegistrationWorkshop[]currencyoptionalstringstepsoptionalDoneStep[]submitLabeloptionalstringdoneoptional{ 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
| Key | English | German |
|---|---|---|
| tickets | English Tickets | German Tickets |
| name | English Name | German Name |
| firstName | English First name | German Vorname |
| lastName | English Last name | German Nachname |
| nameNeeded | English Please add a name, it goes on the ticket. | German Bitte gib einen Namen an, er steht auf dem Ticket. |
| English Email | German E-Mail | |
| emailNeeded | English 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. |
| attendee | English Participant {n} | German Person {n} |
| same | English This is me | German Das bin ich |
| diet | English Food | German Essen |
| total | English Total | German Gesamt |
| fromPrice | English from {p} | German ab {p} |
| session | English Choose a date | German Wähle einen Termin |
| sessionNeeded | English Please choose a date, so we can hold a seat for you. | German Bitte wähle einen Termin, damit wir dir einen Platz freihalten. |
| left | English {n} of {total} seats left | German Noch {n} von {total} Plätzen |
| full | English Full, waitlist open | German Voll, Warteliste offen |
| waitlistNote | English 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. |
| teamName | English Team name | German Teamname |
| teamNeeded | English Please name your team, it goes on the start list. | German Bitte gib eurem Team einen Namen, er steht auf der Startliste. |
| category | English Category | German Kategorie |
| categoryNeeded | English Please choose a category, it decides your start time. | German Bitte wähle eine Kategorie, sie bestimmt eure Startzeit. |
| captain | English Captain | German Kapitänin oder Kapitän |
| members | English Team members | German Teammitglieder |
| member | English Member {n} | German Mitglied {n} |
| addMember | English Add a member | German Mitglied hinzufügen |
| removeMember | English Remove member {n} | German Mitglied {n} entfernen |
| membersCount | English {n} of {min} needed | German {n} von {min} nötig |
| membersReady | English {n} members, ready to start | German {n} Mitglieder, startklar |
| membersNeeded | English Please add at least {n} members, the rules ask for it. | German Bitte trage mindestens {n} Mitglieder ein, das verlangen die Regeln. |
| size | English Shirt size | German Shirtgröße |
| ticket | English Ticket | German Ticket |
| ticketNeeded | English Please choose a ticket. | German Bitte wähle ein Ticket. |
| proof | English Proof | German Nachweis |
| proofNeeded | English 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. |
| workshops | English Workshops | German Workshops |
| workshopsHint | English One per slot, workshops in the same slot run at the same time | German Einer je Zeitfenster, Workshops im selben Fenster laufen gleichzeitig |
| clash | English {a} and {b} run at the same time. Please keep one of them. | German {a} und {b} laufen gleichzeitig. Bitte behalte einen davon. |
| slot | English Slot {s} | German Um {s} Uhr |
| consent | English I agree that my details are used to run this event. | German Ich bin einverstanden, dass meine Angaben für diese Veranstaltung genutzt werden. |
| consentNeeded | English Please agree so we are allowed to register you. | German Bitte stimme zu, damit wir dich anmelden dürfen. |
| send | English Register | German Anmelden |
| sendWait | English Join the waitlist | German Auf die Warteliste |
| doneTitle | English You are registered | German Du bist angemeldet |
| doneText | English Your confirmation and tickets are in your inbox. | German Bestätigung und Tickets liegen in deinem Postfach. |
| waitTitle | English You are on the waitlist | German Du stehst auf der Warteliste |
| again | English Register someone else | German 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
'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.