Skip to content
UI Kit
Accent color

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.

4 variantsOne of 2 components in Events and webinars

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 and formNew in 1.2

A calendar block with time, length and a live tag, what people take away and who speaks on the left, a form with errors per field on the right that ends with a calendar link.

Pick a dateNew in 1.2

Several dates as cards with the time in the format of the language and seats left, few seats show in a warm tone, full dates rest. The button names the chosen date.

Speaker stageNew in 1.2

The speakers large on a dark stage with a soft light behind each portrait and a floor of lines, a live tag on top and a clock to the start beside the main button.

Recording behind a formNew in 1.2

The recording rests blurred behind a lock with its chapters beside it. After a short form the picture clears, the chapters become buttons and the current one is named on the video.

Details

What Webinar sign-up brings along, read from the registry and the source.

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

Props

The WebinarSignupProps interface from components/ui/webinar-signup.tsx, with the comments from the code.

  • variantoptional
    WebinarSignupVariant

    split-registersession-pickerspeaker-stagereplay-gated

  • eyebrowoptional
    React.ReactNode
  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • dateoptional
    { day: string; month: string; weekday?: string }

    split-register, speaker-stage: the date as day, month and weekday

  • timeoptional
    string

    HH:MM

  • durationoptional
    string

    e.g. 45 min

  • zoneoptional
    string

    Time zone as text, e.g. CET

  • speakersoptional
    WebinarSpeaker[]
  • agendaoptional
    string[]

    What people take away

  • sessionsoptional
    WebinarSession[]

    session-picker

  • chaptersoptional
    { time: string; title: string }[]

    replay-gated

  • mediaoptional
    React.ReactNode

    replay-gated: the still before the form, and the player after it

  • remainingoptional
    number

    speaker-stage: seconds until the start

  • companyoptional
    boolean

    split-register: ask for the company

  • calendaroptional
    { label: string; href: string }
  • ctaoptional
    { label: string; href: string }

    speaker-stage: the main action

  • whenoptional
    string

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

  • formActionoptional
    string
  • registeredoptional
    boolean

    Studio preview: start registered

  • noteoptional
    string

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
nameEnglish Full nameGerman Vor- und Nachname
namePhEnglish Alex MorganGerman Alex Morgen
emailEnglish Work emailGerman Geschäftliche E-Mail
emailPhEnglish you@company.comGerman du@firma.de
companyEnglish CompanyGerman Firma
optionalEnglish OptionalGerman Optional
companyPhEnglish Northwind StudioGerman Studio Nordwind
registerEnglish Save my seatGerman Platz sichern
registerForEnglish Save my seat for {d}German Platz am {d} sichern
sendingEnglish Saving your seatGerman Platz wird gesichert
invalidNameEnglish Please add your name, it goes on the welcome email.German Bitte ergänze deinen Namen, er steht in der Willkommensmail.
invalidEmailEnglish Please enter a full email address, like name@company.com.German Bitte gib eine vollständige E-Mail-Adresse ein, etwa name@firma.de.
pickSessionEnglish Please pick a date first.German Bitte wähle zuerst einen Termin.
failedEnglish That did not go through. Your details are still here, please try again.German Das hat nicht geklappt. Deine Angaben sind noch da, bitte versuch es noch einmal.
doneEnglish Your seat is savedGerman Dein Platz ist gesichert
doneTextEnglish 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.
speakersEnglish SpeakersGerman Sprecher
learnEnglish What you take awayGerman Was du mitnimmst
liveEnglish LiveGerman Live
freeEnglish FreeGerman Kostenlos
seatsEnglish {n} seats leftGerman Noch {n} Plätze
fullEnglish Fully bookedGerman Ausgebucht
pickEnglish Pick a dateGerman Termin wählen
startsInEnglish Starts inGerman Beginnt in
daysEnglish dGerman Tg.
hoursEnglish hGerman Std.
minutesEnglish minGerman Min.
secondsEnglish sGerman Sek.
replayEnglish Watch the recordingGerman Aufzeichnung ansehen
unlockEnglish Open the recordingGerman Aufzeichnung öffnen
chaptersEnglish ChaptersGerman Kapitel
lockedEnglish Opens after you sign upGerman Öffnet nach der Anmeldung
playingEnglish Enjoy the recordingGerman 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
components/ui/webinar-signup.tsx
'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.

Often searched

Jump to

Copied to the clipboard