Webinar registration page
Sign-ups for an online event with the facts people check first: a date block and agenda beside the form, several dates with seats left, the speakers on a dark stage and a recording that opens after a short form.
From interest to a seat in the session
A webinar registration page has one job, to turn a visitor into an attendee with a date in their calendar. The four versions here cover a single session, several dates, the speakers as the draw and the recording after the event.
Details and form puts a calendar block with time, length and a live tag on the left with what people take away and who speaks, and a form with errors per field on the right that ends with a calendar link. Pick a date shows several dates as cards with the time in the format of the language and seats left, few seats show in a warm tone and the button names the chosen date. Speaker stage sets the speakers large on a dark stage with a soft light behind each portrait, a live tag and a clock to the start beside the main button. Recording behind a form rests the recording blurred behind a lock with its chapters beside it, after a short form the picture clears and the chapters become buttons.
Say in one line what people can do after the session. Ask only for name and email for a free webinar. Offer the recording to everyone who could not make it, it keeps collecting sign ups long after the date.
Variants
Details
What Webinar sign-up brings along, read from the registry and the source.
- Category
- Events and webinars
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/webinar-signup.tsx
- npm packages
- None
- Languages
- English and German, 33 texts of its own
- Studio
- Adjust in code
- In collections
- Event
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WebinarSignupProps interface from components/ui/webinar-signup.tsx, with the comments from the code.
variantoptionalWebinarSignupVariantsplit-registersession-pickerspeaker-stagereplay-gated
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodedateoptional{ day: string; month: string; weekday?: string }split-register, speaker-stage: the date as day, month and weekday
timeoptionalstringHH:MM
durationoptionalstringe.g. 45 min
zoneoptionalstringTime zone as text, e.g. CET
speakersoptionalWebinarSpeaker[]agendaoptionalstring[]What people take away
sessionsoptionalWebinarSession[]session-picker
chaptersoptional{ time: string; title: string }[]replay-gated
mediaoptionalReact.ReactNodereplay-gated: the still before the form, and the player after it
remainingoptionalnumberspeaker-stage: seconds until the start
companyoptionalbooleansplit-register: ask for the company
calendaroptional{ label: string; href: string }ctaoptional{ label: string; href: string }speaker-stage: the main action
whenoptionalstringspeaker-stage: date and time as one text, e.g. Thursday, November 20 at 5 PM
onRegisteroptional(data: WebinarRegistration) => void | Promise<void>Resolve to show success, throw to show the error
formActionoptionalstringregisteredoptionalbooleanStudio preview: start registered
noteoptionalstring
Built-in texts
Webinar sign-up writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 33 texts
| Key | English | German |
|---|---|---|
| name | English Full name | German Vor- und Nachname |
| namePh | English Alex Morgan | German Alex Morgen |
| English Work email | German Geschäftliche E-Mail | |
| emailPh | English you@company.com | German du@firma.de |
| company | English Company | German Firma |
| optional | English Optional | German Optional |
| companyPh | English Northwind Studio | German Studio Nordwind |
| register | English Save my seat | German Platz sichern |
| registerFor | English Save my seat for {d} | German Platz am {d} sichern |
| sending | English Saving your seat | German Platz wird gesichert |
| invalidName | English Please add your name, it goes on the welcome email. | German Bitte ergänze deinen Namen, er steht in der Willkommensmail. |
| invalidEmail | English Please enter a full email address, like name@company.com. | German Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de. |
| pickSession | English Please pick a date first. | German Bitte wähle zuerst einen Termin. |
| failed | English That did not go through. Your details are still here, please try again. | German Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal. |
| done | English Your seat is saved | German Dein Platz ist gesichert |
| doneText | English The link to join is in your inbox. We send a reminder an hour before. | German Der Link zum Einwählen liegt in deinem Postfach. Eine Stunde vorher erinnern wir dich. |
| speakers | English Speakers | German Sprecher |
| learn | English What you take away | German Was du mitnimmst |
| live | English Live | German Live |
| free | English Free | German Kostenlos |
| seats | English {n} seats left | German Noch {n} Plätze |
| full | English Fully booked | German Ausgebucht |
| pick | English Pick a date | German Termin wählen |
| startsIn | English Starts in | German Beginnt in |
| days | English d | German Tg. |
| hours | English h | German Std. |
| minutes | English min | German Min. |
| seconds | English s | German Sek. |
| replay | English Watch the recording | German Aufzeichnung ansehen |
| unlock | English Open the recording | German Aufzeichnung öffnen |
| chapters | English Chapters | German Kapitel |
| locked | English Opens after you sign up | German Öffnet nach der Anmeldung |
| playing | English Enjoy the recording | German Viel Freude mit der Aufzeichnung |
Accessibility
Read from the source of the component.
- Sets aria-current, aria-describedby and aria-invalid, so screen readers announce purpose and state.
- Uses the roles alert, status and timer.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 619 lines
'use client';
import { useEffect, useId, useState } from 'react';
import { cn, formatTime, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* A sign-up for one online event, with the facts people check first: when,
* how long, who speaks, what they take away. Four layouts:
*
* split-register date block, agenda and speakers beside a full form
* session-picker several dates as cards with seats left, then one field
* speaker-stage the speakers large on a dark stage, a clock to the start
* replay-gated a recording with chapters that opens after a short form
*
* Times are written as HH:MM and shown in the format of the language.
*/
export type WebinarSignupVariant = 'split-register' | 'session-picker' | 'speaker-stage' | 'replay-gated';
export interface WebinarSpeaker {
name: string;
role?: string;
/** A portrait, shown round. Without it a monogram is drawn. */
photo?: React.ReactNode;
}
export interface WebinarSession {
/** As text in the language, e.g. Tue, Nov 18 */
date: string;
/** HH:MM */
time: string;
seats?: number;
}
export interface WebinarRegistration {
name: string;
email: string;
company?: string;
session?: number;
}
export interface WebinarSignupProps {
variant?: WebinarSignupVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** split-register, speaker-stage: the date as day, month and weekday */
date?: { day: string; month: string; weekday?: string };
/** HH:MM */
time?: string;
/** e.g. 45 min */
duration?: string;
/** Time zone as text, e.g. CET */
zone?: string;
speakers?: WebinarSpeaker[];
/** What people take away */
agenda?: string[];
/** session-picker */
sessions?: WebinarSession[];
/** replay-gated */
chapters?: { time: string; title: string }[];
/** replay-gated: the still before the form, and the player after it */
media?: React.ReactNode;
/** speaker-stage: seconds until the start */
remaining?: number;
/** split-register: ask for the company */
company?: boolean;
calendar?: { label: string; href: string };
/** speaker-stage: the main action */
cta?: { label: string; href: string };
/** speaker-stage: date and time as one text, e.g. Thursday, November 20 at 5 PM */
when?: string;
/** Resolve to show success, throw to show the error */
onRegister?: (data: WebinarRegistration) => void | Promise<void>;
formAction?: string;
/** Studio preview: start registered */
registered?: boolean;
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
name: 'Full name',
namePh: 'Alex Morgan',
email: 'Work email',
emailPh: 'you@company.com',
company: 'Company',
optional: 'Optional',
companyPh: 'Northwind Studio',
register: 'Save my seat',
registerFor: 'Save my seat for {d}',
sending: 'Saving your seat',
invalidName: 'Please add your name, it goes on the welcome email.',
invalidEmail: 'Please enter a full email address, like name@company.com.',
pickSession: 'Please pick a date first.',
failed: 'That did not go through. Your details are still here, please try again.',
done: 'Your seat is saved',
doneText: 'The link to join is in your inbox. We send a reminder an hour before.',
speakers: 'Speakers',
learn: 'What you take away',
live: 'Live',
free: 'Free',
seats: '{n} seats left',
full: 'Fully booked',
pick: 'Pick a date',
startsIn: 'Starts in',
days: 'd',
hours: 'h',
minutes: 'min',
seconds: 's',
replay: 'Watch the recording',
unlock: 'Open the recording',
chapters: 'Chapters',
locked: 'Opens after you sign up',
playing: 'Enjoy the recording',
},
de: {
name: 'Vor- und Nachname',
namePh: 'Alex Morgen',
email: 'Geschäftliche E-Mail',
emailPh: 'du@firma.de',
company: 'Firma',
optional: 'Optional',
companyPh: 'Studio Nordwind',
register: 'Platz sichern',
registerFor: 'Platz am {d} sichern',
sending: 'Platz wird gesichert',
invalidName: 'Bitte ergänze deinen Namen, er steht in der Willkommensmail.',
invalidEmail: 'Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.',
pickSession: 'Bitte wähle zuerst einen Termin.',
failed: 'Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.',
done: 'Dein Platz ist gesichert',
doneText: 'Der Link zum Einwählen liegt in deinem Postfach. Eine Stunde vorher erinnern wir dich.',
speakers: 'Sprecher',
learn: 'Was du mitnimmst',
live: 'Live',
free: 'Kostenlos',
seats: 'Noch {n} Plätze',
full: 'Ausgebucht',
pick: 'Termin wählen',
startsIn: 'Beginnt in',
days: 'Tg.',
hours: 'Std.',
minutes: 'Min.',
seconds: 'Sek.',
replay: 'Aufzeichnung ansehen',
unlock: 'Aufzeichnung öffnen',
chapters: 'Kapitel',
locked: 'Öffnet nach der Anmeldung',
playing: 'Viel Freude mit der Aufzeichnung',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = WebinarSignupProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const FIELD =
'h-12 w-full min-w-0 rounded-lg border bg-background px-4 text-base text-foreground placeholder:text-subtle-foreground transition-[border-color,box-shadow] duration-200 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none sm:text-[15px]';
const initials = (n: string) =>
n
.split(/\s+/)
.filter((w) => /^[A-ZÄÖÜ]/.test(w))
.map((w) => w[0])
.slice(-2)
.join('');
function Face({ s, className }: { s: WebinarSpeaker; className?: string }) {
return (
<span data-clip-ok className={cn('grid shrink-0 place-items-center overflow-hidden rounded-full border-2 border-background bg-[color-mix(in_oklab,var(--primary)_22%,var(--card))] text-[13px] font-semibold text-foreground [&>*]:size-full [&>*]:object-cover', className)} aria-hidden>
{s.photo ?? initials(s.name)}
</span>
);
}
function FormError({ id, children }: { id: string; children: React.ReactNode }) {
return (
<p id={id} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
<span>{children}</span>
</p>
);
}
function Done({ t, calendar, className }: { t: Texts; calendar?: WebinarSignupProps['calendar']; className?: string }) {
return (
<div role="status" className={cn('kit-rise text-center', className)}>
<span className="mx-auto grid size-14 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success">
<Icon name="calendar-check" size={24} />
</span>
<p className="mt-4 text-[19px] font-semibold text-foreground">{t.done}</p>
<p className="mx-auto mt-2 max-w-[22rem] text-pretty text-[14.5px] text-muted-foreground">{t.doneText}</p>
{calendar && (
<Button href={calendar.href} variant="outline" iconLeft="calendar" className="mt-5 max-sm:w-full">
{calendar.label}
</Button>
)}
</div>
);
}
/** Name, email and an optional company, with errors per field */
function RegisterForm({ t, p, session, submitLabel, onDone, needSession }: { t: Texts; p: WebinarSignupProps; session?: number; submitLabel: string; onDone: () => void; needSession?: boolean }) {
const id = useId();
const [errors, setErrors] = useState<Partial<Record<'name' | 'email' | 'session', string>>>({});
const [state, setState] = useState<'idle' | 'sending' | 'failed'>('idle');
return (
<form
action={p.formAction}
method="post"
noValidate
className="grid gap-4"
onSubmit={async (e) => {
const f = new FormData(e.currentTarget);
const data: WebinarRegistration = { name: String(f.get('name') ?? '').trim(), email: String(f.get('email') ?? '').trim(), company: String(f.get('company') ?? '').trim() || undefined, session };
const next: typeof errors = {};
if (needSession && session === undefined) next.session = t.pickSession;
if (!data.name) next.name = t.invalidName;
if (!EMAIL.test(data.email)) next.email = t.invalidEmail;
setErrors(next);
const first = (['name', 'email'] as const).find((k) => next[k]);
if (Object.keys(next).length) {
e.preventDefault();
if (first) (e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus();
return;
}
if (!p.onRegister && p.formAction) return;
e.preventDefault();
setState('sending');
try {
await (p.onRegister ? p.onRegister(data) : new Promise((r) => setTimeout(r, 800)));
onDone();
} catch {
setState('failed');
}
}}
>
{session !== undefined && <input type="hidden" name="session" value={session} />}
<div>
<label htmlFor={`${id}-n`} className="text-sm font-medium text-foreground">
{t.name}
</label>
<input id={`${id}-n`} name="name" autoComplete="name" placeholder={t.namePh} aria-invalid={Boolean(errors.name) || undefined} aria-describedby={errors.name ? `${id}-ne` : undefined} onInput={() => errors.name && setErrors((x) => ({ ...x, name: undefined }))} className={cn(FIELD, 'mt-2', errors.name ? 'border-destructive' : 'border-border')} />
{errors.name && <FormError id={`${id}-ne`}>{errors.name}</FormError>}
</div>
<div>
<label htmlFor={`${id}-e`} className="text-sm font-medium text-foreground">
{t.email}
</label>
<input id={`${id}-e`} name="email" type="email" inputMode="email" autoComplete="email" placeholder={t.emailPh} aria-invalid={Boolean(errors.email) || undefined} aria-describedby={errors.email ? `${id}-ee` : undefined} onInput={() => errors.email && setErrors((x) => ({ ...x, email: undefined }))} className={cn(FIELD, 'mt-2', errors.email ? 'border-destructive' : 'border-border')} />
{errors.email && <FormError id={`${id}-ee`}>{errors.email}</FormError>}
</div>
{p.company && (
<div>
<label htmlFor={`${id}-c`} className="inline-flex items-baseline gap-1.5 text-sm font-medium text-foreground">
{t.company}
<span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
</label>
<input id={`${id}-c`} name="company" autoComplete="organization" placeholder={t.companyPh} className={cn(FIELD, 'mt-2 border-border')} />
</div>
)}
{errors.session && <FormError id={`${id}-se`}>{errors.session}</FormError>}
<Button type="submit" size="lg" disabled={state === 'sending'} icon={state === 'sending' ? undefined : 'arrow-right'} className="mt-1 w-full">
{state === 'sending' && <Icon name="loader" size={17} className="animate-kit-spin [--kit-duration:0.8s]" />}
<span className="truncate">{state === 'sending' ? t.sending : submitLabel}</span>
</Button>
{state === 'failed' && <FormError id={`${id}-f`}>{t.failed}</FormError>}
{p.note && <p className="text-center text-[13px] text-muted-foreground">{p.note}</p>}
</form>
);
}
function Head({ eyebrow, title, description, className }: Pick<WebinarSignupProps, 'eyebrow' | 'title' | 'description'> & { className?: string }) {
return (
<div className={cn('max-w-[40rem]', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={delay(80)}>
{description}
</p>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* split-register */
/* ------------------------------------------------------------------ */
function SplitRegister(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const [done, setDone] = useState(Boolean(p.registered));
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,27rem)] lg:gap-20">
<div>
<div className="kit-rise flex items-center gap-4">
{p.date && (
<span className="flex w-[4.5rem] shrink-0 flex-col items-center overflow-hidden rounded-xl border border-border bg-card text-center shadow-[var(--highlight),var(--shadow-sm)]">
<span className="w-full bg-primary py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-foreground">{p.date.month}</span>
<span className="py-1.5 text-[28px] font-semibold leading-none tabular-nums tracking-[-0.03em] text-foreground">{p.date.day}</span>
</span>
)}
<span className="grid min-w-0 gap-1 text-[14px] text-muted-foreground">
{p.date?.weekday && <span className="font-semibold text-foreground">{p.date.weekday}</span>}
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
{p.time && (
<span className="inline-flex items-center gap-1.5">
<Icon name="clock" size={15} />
{formatTime(p.time, locale)} {p.zone}
</span>
)}
{p.duration && (
<span className="inline-flex items-center gap-1.5">
<Icon name="hourglass" size={15} />
{p.duration}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-success-foreground">
<span className="size-1.5 rounded-full bg-success" aria-hidden />
{t.live} · {t.free}
</span>
</span>
</span>
</div>
<Head {...p} className="mt-8" />
{p.agenda?.length ? (
<div className="mt-10">
<h3 className="text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.learn}</h3>
<ol className="mt-4 grid gap-3">
{p.agenda.map((a, i) => (
<li key={i} className="kit-rise flex gap-3.5 text-pretty text-[15.5px] text-foreground" style={delay(140 + i * 60)}>
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[12.5px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{i + 1}</span>
<span className="pt-0.5">{a}</span>
</li>
))}
</ol>
</div>
) : null}
{p.speakers?.length ? (
<div className="mt-10">
<h3 className="text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.speakers}</h3>
<ul className="mt-4 grid gap-4 sm:flex sm:flex-wrap sm:gap-x-8">
{p.speakers.map((s) => (
<li key={s.name} className="flex items-center gap-3">
<Face s={s} className="size-12" />
<span>
<span className="block text-[15px] font-semibold text-foreground">{s.name}</span>
{s.role && <span className="block text-[13px] text-muted-foreground">{s.role}</span>}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
<div className="kit-rise lg:sticky lg:top-24 lg:self-start" style={delay(160)}>
<div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
{done ? <Done t={t} calendar={p.calendar} className="py-4" /> : <RegisterForm t={t} p={p} submitLabel={t.register} onDone={() => setDone(true)} />}
</div>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* session-picker */
/* ------------------------------------------------------------------ */
function SessionPicker(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const id = useId();
const sessions = p.sessions ?? [];
const firstOpen = sessions.findIndex((s) => s.seats === undefined || s.seats > 0);
const [pick, setPick] = useState<number | undefined>(firstOpen >= 0 ? firstOpen : undefined);
const [done, setDone] = useState(Boolean(p.registered));
const chosen = pick !== undefined ? sessions[pick] : undefined;
return (
<Section background="surface" className={p.className}>
<Head {...p} className="mx-auto text-center [&_p]:mx-auto" />
<div className="mx-auto mt-12 max-w-[52rem]">
{done ? (
<div className="rounded-2xl border border-border bg-card p-8 shadow-[var(--highlight),var(--shadow-md)]">
<Done t={t} calendar={p.calendar} />
</div>
) : (
<>
<fieldset className="min-w-0">
<legend className="mb-4 w-full text-center text-[14px] font-semibold text-foreground">
{t.pick}
{p.zone && <span className="ml-2 font-normal text-muted-foreground">({p.zone})</span>}
</legend>
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-3">
{sessions.map((s, i) => {
const full = s.seats === 0;
const on = pick === i;
return (
<label
key={i}
className={cn(
'kit-rise relative flex cursor-pointer items-center gap-4 rounded-2xl border bg-card p-4 transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-primary sm:flex-col sm:items-start sm:p-5',
on ? 'border-primary shadow-[var(--highlight),0_0_0_4px_var(--glow),var(--shadow-md)]' : 'border-border shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0',
full && 'cursor-not-allowed opacity-55 hover:translate-y-0',
)}
style={delay(i * 70)}
>
<input type="radio" name={`${id}-s`} value={i} checked={on} disabled={full} onChange={() => setPick(i)} className="sr-only" />
<span className={cn('grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors sm:absolute sm:right-4 sm:top-4', on ? 'border-primary' : 'border-border-strong')} aria-hidden>
<span className={cn('size-2.5 rounded-full bg-primary transition-transform duration-300', on ? 'scale-100' : 'scale-0')} />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold text-foreground">{s.date}</span>
<span className="mt-0.5 block text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:mt-2 sm:text-[26px]">{formatTime(s.time, locale)}</span>
<span className={cn('mt-1 block text-[13px]', full ? 'text-muted-foreground' : s.seats !== undefined && s.seats <= 10 ? 'font-medium text-warning-foreground' : 'text-muted-foreground')}>
{full ? t.full : s.seats !== undefined ? fill(t.seats, { n: s.seats }) : p.duration}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="kit-rise mx-auto mt-8 max-w-[30rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-7" style={delay(200)}>
<RegisterForm t={t} p={p} session={pick} needSession submitLabel={chosen ? fill(t.registerFor, { d: chosen.date }) : t.register} onDone={() => setDone(true)} />
</div>
</>
)}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* speaker-stage */
/* ------------------------------------------------------------------ */
function useClock(start?: number) {
const [left, setLeft] = useState(Math.max(0, start ?? 0));
useEffect(() => {
if (start === undefined) return;
const t0 = Date.now();
const id = window.setInterval(() => setLeft(Math.max(0, start - Math.floor((Date.now() - t0) / 1000))), 1000);
return () => window.clearInterval(id);
}, [start]);
return left;
}
function SpeakerStage(p: ViewProps) {
const { t } = p;
const left = useClock(p.remaining);
const parts = [Math.floor(left / 86400), Math.floor((left % 86400) / 3600), Math.floor((left % 3600) / 60), left % 60];
const units = [t.days, t.hours, t.minutes, t.seconds];
const speakers = p.speakers ?? [];
return (
<section className={cn('dark relative overflow-hidden bg-background py-24 text-foreground sm:py-32', p.className)}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-[radial-gradient(60%_70%_at_50%_100%,var(--glow),transparent_70%)]" />
{/* a stage floor of light lines */}
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 [background:repeating-linear-gradient(to_right,transparent_0_79px,color-mix(in_oklab,var(--foreground)_10%,transparent)_79px_80px)] [mask-image:linear-gradient(to_top,black,transparent)] [transform:perspective(400px)_rotateX(60deg)] [transform-origin:bottom]" />
<div className="kit-container relative">
<div className="mx-auto max-w-[44rem] text-center">
<p className="kit-rise inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-[13px] font-medium text-foreground">
<span className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-60 motion-reduce:animate-none" />
<span className="relative size-2 rounded-full bg-destructive" />
</span>
{t.live}
{p.when && <span className="text-muted-foreground">· {p.when}</span>}
</p>
<h2 className="kit-rise mt-6 text-balance text-h1 font-semibold text-foreground" style={delay(60)}>
{p.title}
</h2>
{p.description && (
<p className="kit-rise mx-auto mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={delay(120)}>
{p.description}
</p>
)}
</div>
<ul className={cn('mx-auto mt-14 grid max-w-[60rem] grid-cols-2 gap-6 sm:gap-8 [&>:last-child:nth-child(odd)]:col-span-2', speakers.length >= 3 && 'md:[&>:last-child:nth-child(odd)]:col-span-1', speakers.length >= 3 && 'md:grid-cols-3', speakers.length >= 4 && 'lg:grid-cols-4')}>
{speakers.map((s, i) => (
<li key={s.name} className="kit-rise group/sp flex flex-col items-center text-center" style={delay(180 + i * 90)}>
<span className="relative">
<span aria-hidden className="absolute -inset-2 rounded-full bg-[conic-gradient(from_0deg,var(--primary),transparent_40%,var(--primary))] opacity-40 blur-md transition-opacity duration-500 group-hover/sp:opacity-80" />
<Face s={s} className="relative size-28 border-4 text-[26px] sm:size-36 sm:text-[32px]" />
</span>
<span className="mt-5 text-[16px] font-semibold text-foreground">{s.name}</span>
{s.role && <span className="mt-1 text-pretty text-[13.5px] text-muted-foreground">{s.role}</span>}
</li>
))}
</ul>
<div className="kit-rise mx-auto mt-14 flex max-w-[36rem] flex-col items-center gap-6 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:flex-row sm:justify-between sm:p-6" style={delay(400)}>
<div className="text-center sm:text-left">
<p className="text-[12.5px] text-muted-foreground">{t.startsIn}</p>
<p role="timer" className="mt-1 flex items-baseline justify-center gap-2 tabular-nums sm:justify-start">
{parts.map((v, i) => (
<span key={i} className="inline-flex items-baseline gap-0.5">
<span className="text-[26px] font-semibold tracking-[-0.03em] text-foreground">{String(v).padStart(2, '0')}</span>
<span className="text-[12px] text-muted-foreground">{units[i]}</span>
</span>
))}
</p>
</div>
{p.cta ? (
<Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
{p.cta.label}
</Button>
) : null}
</div>
{p.agenda?.length ? (
<ul className="mx-auto mt-10 flex max-w-[52rem] flex-wrap justify-center gap-2">
{p.agenda.map((a, i) => (
<li key={i} className="rounded-full border border-border bg-[color-mix(in_oklab,var(--card)_70%,transparent)] px-4 py-2 text-[13.5px] text-muted-foreground">
{a}
</li>
))}
</ul>
) : null}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* replay-gated */
/* ------------------------------------------------------------------ */
function ReplayGated(p: ViewProps) {
const { t } = p;
const [open, setOpen] = useState(Boolean(p.registered));
const [chapter, setChapter] = useState(0);
const chapters = p.chapters ?? [];
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]">
<div className="kit-rise relative aspect-video overflow-hidden rounded-2xl border border-border bg-bezel shadow-[var(--highlight),var(--shadow-lg)] [&>*:first-child]:size-full" style={delay(120)}>
{p.media}
{!open && (
<div className="absolute inset-0 grid place-items-center bg-[color-mix(in_oklab,var(--background)_45%,transparent)] backdrop-blur-[6px]">
<div className="flex flex-col items-center gap-3 text-center">
<span className="grid size-16 place-items-center rounded-full border border-border bg-card text-foreground shadow-[var(--shadow-lg)]">
<Icon name="lock" size={24} />
</span>
<p className="rounded-full bg-card px-3 py-1 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)]">{t.locked}</p>
</div>
</div>
)}
{open && (
<p role="status" className="kit-rise absolute left-3 top-3 inline-flex items-center gap-2 rounded-full bg-card px-3 py-1.5 text-[13px] font-medium text-foreground shadow-[var(--shadow-md)]">
<Icon name="play" size={13} className="text-primary" />
{chapters[chapter]?.title ?? t.playing}
</p>
)}
</div>
<div className="kit-rise rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={delay(180)}>
{open ? (
<div className="p-2">
<p className="px-3 pb-2 pt-3 text-[13px] font-medium uppercase tracking-[0.1em] text-subtle-foreground">{t.chapters}</p>
<ol>
{chapters.map((c, i) => (
<li key={i}>
<button
type="button"
aria-current={chapter === i ? 'true' : undefined}
onClick={() => setChapter(i)}
className={cn('flex min-h-12 w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary', chapter === i ? 'bg-accent' : 'hover:bg-surface')}
>
<span className={cn('w-12 shrink-0 text-[13px] font-medium tabular-nums', chapter === i ? 'text-primary' : 'text-muted-foreground')}>{c.time}</span>
<span className="min-w-0 flex-1 text-pretty text-[14.5px] font-medium text-foreground">{c.title}</span>
{chapter === i && <Icon name="activity" size={15} className="shrink-0 text-primary" />}
</button>
</li>
))}
</ol>
</div>
) : (
<div className="p-6">
<p className="text-[16px] font-semibold text-foreground">{t.replay}</p>
<ul className="mb-5 mt-3 grid gap-1.5">
{chapters.slice(0, 4).map((c, i) => (
<li key={i} className="flex gap-3 text-[13.5px] text-muted-foreground">
<span className="w-10 shrink-0 tabular-nums">{c.time}</span>
<span className="min-w-0 truncate">{c.title}</span>
</li>
))}
</ul>
<RegisterForm t={t} p={p} submitLabel={t.unlock} onDone={() => setOpen(true)} />
</div>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<WebinarSignupVariant, (p: ViewProps) => React.ReactNode> = {
'split-register': SplitRegister,
'session-picker': SessionPicker,
'speaker-stage': SpeakerStage,
'replay-gated': ReplayGated,
};
export function WebinarSignup({ variant = 'split-register', labels, ...rest }: WebinarSignupProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? SplitRegister;
return <View {...rest} variant={variant} t={t} />;
}Frequent questions
How do I install Webinar sign-up?
With aiw add @ai-website/webinar-signup. The command puts components/ui/webinar-signup.tsx into your project, the code is yours from then on. It brings along button, icon and section. It needs no further npm packages.
Which variants does Webinar sign-up have?
4 variants, Details and form, Pick a date, Speaker stage and Recording behind a form. You pick one with the variant prop, for example variant="session-picker".
Can I translate the texts of Webinar sign-up?
Yes. Webinar sign-up carries 33 texts of its own in English and German, such as “Full name”, “Alex Morgan” and “Work email”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Webinar sign-up?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Webinar sign-up with aiw_components and install it with aiw_add @ai-website/webinar-signup. Name the variant in your prompt, for example variant="split-register", and the assistant writes your content straight into the props.