Speaker
For keynote speakers, trainers and experts on stage. Talks with formats and takeaways, a showreel with numbers and hosts, past talks by year, a booking enquiry and a one-sheet for organisers.
One of 2 components in Speakers and media kits
Variants
Details
What Speaker brings along, read from the registry and the source.
- Category
- Speakers and media kits
- Area
- Industries
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/speaker.tsx
- Brings along
- utilskitbuttoniconmotionnumber-tickerrevealsection
- npm packages
- None
- Languages
- English and German, 49 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SpeakerProps interface from components/ui/speaker.tsx, with the comments from the code.
variantoptionalSpeakerVarianttopicsreelpast-talksbookingone-sheet
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodenameoptionalstringtaglineoptionalstringe.g. "Keynote speaker on work and attention"
formatsoptionalSpeakerFormat[]topicsoptionalSpeakerTopic[]talksoptionalSpeakerTalk[]mediaoptionalReact.ReactNodereel: the stage picture and the showreel
reelHrefoptionalstringreelLengthoptionalstringstatsoptional{ value: number; suffix?: string; label: string }[]hostsoptionalReact.ReactNode[]reel: logos of hosts so far
hostsLabeloptionalstringbiosoptional{ short: string; medium: string; long: string }one-sheet
introoptionalstringphotosoptional{ label: string; size?: string; href: string; image?: React.ReactNode }[]factsoptional{ label: string; value: string }[]currencyoptionalstringbookHrefoptionalstringonSubmitoptional(data: Record<string, string>) => void
Built-in texts
Speaker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 49 texts
| Key | English | German |
|---|---|---|
| topics | English Talks | German Vorträge |
| formats | English Formats | German Formate |
| takeaways | English What people take home | German Was die Leute mitnehmen |
| audience | English For | German Für |
| request | English Request this talk | German Diesen Vortrag anfragen |
| watch | English Watch the showreel | German Showreel ansehen |
| spokeAt | English Spoke at | German Auf der Bühne bei |
| all | English All | German Alle |
| filter | English Filter by format | German Nach Format filtern |
| recording | English Recording | German Aufzeichnung |
| slides | English Slides | German Folien |
| none | English No talks in this format yet. | German In diesem Format gibt es noch keine Vorträge. |
| date | English Event date | German Datum der Veranstaltung |
| format | English Format | German Format |
| size | English Audience | German Publikum |
| setting | English Setting | German Rahmen |
| onsite | English On site | German Vor Ort |
| online | English Online | German Online |
| sizes | English Up to 100|100 to 500|500 to 2,000|More than 2,000 | German Bis 100|100 bis 500|500 bis 2.000|Mehr als 2.000 |
| event | English Event and organisation | German Veranstaltung und Organisation |
| eventPh | English Annual meeting, Atlas Health | German Jahrestagung, Atlas Health |
| name | English Your name | German Dein Name |
| English Email | German E-Mail | |
| emailPh | English you@example.com | German du@beispiel.de |
| message | English What is the occasion? | German Worum geht es? |
| messagePh | English Who is in the room and what should change for them? | German Wer sitzt im Saal und was soll sich für diese Menschen ändern? |
| send | English Send enquiry | German Anfrage senden |
| required | English Please fill in this field. | German Bitte fülle dieses Feld aus. |
| invalidEmail | English Please enter an email address like name@example.com. | German Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein. |
| sentTitle | English Enquiry received | German Anfrage ist angekommen |
| sentText | English You get an answer within two working days, with availability for {date} and a proposal. | German Du bekommst innerhalb von zwei Werktagen eine Antwort, mit der Verfügbarkeit für {date} und einem Vorschlag. |
| sentTextOpen | English You get an answer within two working days, with open dates and a proposal. | German Du bekommst innerhalb von zwei Werktagen eine Antwort, mit freien Terminen und einem Vorschlag. |
| again | English Send another enquiry | German Weitere Anfrage senden |
| summary | English Your enquiry | German Deine Anfrage |
| from | English Fee from | German Honorar ab |
| travel | English plus travel | German zzgl. Reise |
| open | English open | German offen |
| bio | English Bio | German Bio |
| short | English Short | German Kurz |
| medium | English Medium | German Mittel |
| long | English Long | German Lang |
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| words | English {n} words | German {n} Wörter |
| introTitle | English Intro for the host | German Anmoderation |
| photos | English Press photos | German Pressefotos |
| download | English Download | German Herunterladen |
| facts | English Facts | German Fakten |
| lengthLabel | English Bio length | German Länge der Bio |
Accessibility
Read from the source of the component.
- Speaker handles the keys Arrow up, Arrow down, Arrow left and Arrow right itself.
- Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live, aria-orientation and aria-selected, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup, status, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 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, 828 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, useLocale, useSpotlight, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Parallax, Stagger, useScrollFade, Magnetic } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Section, SectionHeader } from '@/components/ui/section';
/*
* The page of a speaker: what they talk about, how they sound on stage,
* where they spoke, how to book them and what a host needs.
*
* topics talks as a list, the chosen one with abstract, formats and takeaways, an accordion on phones
* reel a stage picture with the showreel, numbers that count up and the hosts so far
* past-talks every talk by year, filtered by format, with recording and slides
* booking an enquiry with format cards, audience and setting, a summary that follows along
* one-sheet bios in three lengths to copy, the intro for the host, press photos and facts
*/
export type SpeakerVariant = 'topics' | 'reel' | 'past-talks' | 'booking' | 'one-sheet';
export interface SpeakerFormat {
id: string;
label: string;
/** e.g. "45 minutes" */
length: string;
icon?: IconName;
/** Fee from, for the booking summary */
fee?: number;
}
export interface SpeakerTopic {
id: string;
title: string;
/** One line under the title */
hook?: string;
abstract: string;
/** ids of formats this talk exists in */
formats: string[];
takeaways?: string[];
audience?: string;
href?: string;
}
export interface SpeakerTalk {
id: string;
title: string;
event: string;
city?: string;
/** YYYY-MM-DD */
date: string;
/** format id */
format: string;
recording?: string;
slides?: string;
}
export interface SpeakerProps {
variant?: SpeakerVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
name?: string;
/** e.g. "Keynote speaker on work and attention" */
tagline?: string;
formats?: SpeakerFormat[];
topics?: SpeakerTopic[];
talks?: SpeakerTalk[];
/** reel: the stage picture and the showreel */
media?: React.ReactNode;
reelHref?: string;
reelLength?: string;
stats?: { value: number; suffix?: string; label: string }[];
/** reel: logos of hosts so far */
hosts?: React.ReactNode[];
hostsLabel?: string;
/** one-sheet */
bios?: { short: string; medium: string; long: string };
intro?: string;
photos?: { label: string; size?: string; href: string; image?: React.ReactNode }[];
facts?: { label: string; value: string }[];
currency?: string;
bookHref?: string;
onSubmit?: (data: Record<string, string>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
topics: 'Talks',
formats: 'Formats',
takeaways: 'What people take home',
audience: 'For',
request: 'Request this talk',
watch: 'Watch the showreel',
spokeAt: 'Spoke at',
all: 'All',
filter: 'Filter by format',
recording: 'Recording',
slides: 'Slides',
none: 'No talks in this format yet.',
date: 'Event date',
format: 'Format',
size: 'Audience',
setting: 'Setting',
onsite: 'On site',
online: 'Online',
sizes: 'Up to 100|100 to 500|500 to 2,000|More than 2,000',
event: 'Event and organisation',
eventPh: 'Annual meeting, Atlas Health',
name: 'Your name',
email: 'Email',
emailPh: 'you@example.com',
message: 'What is the occasion?',
messagePh: 'Who is in the room and what should change for them?',
send: 'Send enquiry',
required: 'Please fill in this field.',
invalidEmail: 'Please enter an email address like name@example.com.',
sentTitle: 'Enquiry received',
sentText: 'You get an answer within two working days, with availability for {date} and a proposal.',
sentTextOpen: 'You get an answer within two working days, with open dates and a proposal.',
again: 'Send another enquiry',
summary: 'Your enquiry',
from: 'Fee from',
travel: 'plus travel',
open: 'open',
bio: 'Bio',
short: 'Short',
medium: 'Medium',
long: 'Long',
copy: 'Copy',
copied: 'Copied',
words: '{n} words',
introTitle: 'Intro for the host',
photos: 'Press photos',
download: 'Download',
facts: 'Facts',
lengthLabel: 'Bio length',
},
de: {
topics: 'Vorträge',
formats: 'Formate',
takeaways: 'Was die Leute mitnehmen',
audience: 'Für',
request: 'Diesen Vortrag anfragen',
watch: 'Showreel ansehen',
spokeAt: 'Auf der Bühne bei',
all: 'Alle',
filter: 'Nach Format filtern',
recording: 'Aufzeichnung',
slides: 'Folien',
none: 'In diesem Format gibt es noch keine Vorträge.',
date: 'Datum der Veranstaltung',
format: 'Format',
size: 'Publikum',
setting: 'Rahmen',
onsite: 'Vor Ort',
online: 'Online',
sizes: 'Bis 100|100 bis 500|500 bis 2.000|Mehr als 2.000',
event: 'Veranstaltung und Organisation',
eventPh: 'Jahrestagung, Atlas Health',
name: 'Dein Name',
email: 'E-Mail',
emailPh: 'du@beispiel.de',
message: 'Worum geht es?',
messagePh: 'Wer sitzt im Saal und was soll sich für diese Menschen ändern?',
send: 'Anfrage senden',
required: 'Bitte fülle dieses Feld aus.',
invalidEmail: 'Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.',
sentTitle: 'Anfrage ist angekommen',
sentText: 'Du bekommst innerhalb von zwei Werktagen eine Antwort, mit der Verfügbarkeit für {date} und einem Vorschlag.',
sentTextOpen: 'Du bekommst innerhalb von zwei Werktagen eine Antwort, mit freien Terminen und einem Vorschlag.',
again: 'Weitere Anfrage senden',
summary: 'Deine Anfrage',
from: 'Honorar ab',
travel: 'zzgl. Reise',
open: 'offen',
bio: 'Bio',
short: 'Kurz',
medium: 'Mittel',
long: 'Lang',
copy: 'Kopieren',
copied: 'Kopiert',
words: '{n} Wörter',
introTitle: 'Anmoderation',
photos: 'Pressefotos',
download: 'Herunterladen',
facts: 'Fakten',
lengthLabel: 'Länge der Bio',
},
};
type Texts = typeof TEXTS.en;
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
function money(value: number, currency: string, locale: Locale) {
return new Intl.NumberFormat(TAG[locale], { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
}
function day(iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) {
const [y, m, d] = iso.split('-').map(Number);
return new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(new Date(Date.UTC(y, m - 1, d || 1)));
}
const MOTION_CSS = `
@keyframes kitspk-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitspk-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitspk-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitspk-ring{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.7);opacity:0}}
@media (prefers-reduced-motion:reduce){[class*="kitspk-"],.kitspk-anim{animation:none!important}}`;
function SpkMotion() {
return (
<style href="kitspk-motion" precedence="default">
{MOTION_CSS}
</style>
);
}
function useCopy() {
const [done, setDone] = useState<string | null>(null);
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = async (key: string, text: string) => {
try {
await navigator.clipboard?.writeText(text);
} catch {
/* the check still confirms the press */
}
setDone(key);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setDone(null), 1800);
};
return [done, copy] as const;
}
function CopyButton({ on, onClick, t }: { on: boolean; onClick: () => void; t: Texts }) {
return (
<button type="button" onClick={onClick} className={cn('inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-[background-color,border-color,color] duration-300 max-sm:min-h-11', focusRing, on ? 'border-transparent bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'border-border text-foreground hover:bg-accent')}>
<span key={String(on)} className={cn('inline-flex', on && '[animation:kitspk-pop_.4s_var(--ease-kit)]')}>
<Icon name={on ? 'check' : 'copy'} size={15} />
</span>
<span aria-live="polite">{on ? t.copied : t.copy}</span>
</button>
);
}
/** A row of choices with a marker that glides to the active one */
function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
const wrap = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number } | null>(null);
useEffect(() => {
const el = wrap.current;
if (!el) return;
const measure = () => {
const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [value]);
const key = (e: React.KeyboardEvent) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const i = options.findIndex((o) => o.id === value);
const n = options[(i + d + options.length) % options.length];
onChange(n.id);
wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
};
return (
<div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
{box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
{options.map((o) => {
const on = o.id === value;
return (
<button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 flex-1 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11 sm:flex-none', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* topics */
/* ------------------------------------------------------------------ */
function TopicDetail({ topic, formats, t, bookHref, compact }: { topic: SpeakerTopic; formats: SpeakerFormat[]; t: Texts; bookHref?: string; compact?: boolean }) {
const own = formats.filter((f) => topic.formats.includes(f.id));
return (
<div className="[animation:kitspk-swap_.5s_var(--ease-kit)]">
{!compact && topic.hook && <p className="text-[14px] font-medium text-primary">{topic.hook}</p>}
{!compact && <h3 className="mt-2 text-balance text-[26px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[30px]">{topic.title}</h3>}
<p className={cn('text-pretty text-[15.5px] leading-relaxed text-foreground/85', !compact && 'mt-4')}>{topic.abstract}</p>
{own.length > 0 && (
<ul aria-label={t.formats} className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2">
{own.map((f) => (
<li key={f.id} className="flex items-center gap-3 bg-card px-4 py-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
<Icon name={f.icon ?? 'mic'} size={17} />
</span>
<span className="min-w-0">
<span className="block text-[14.5px] font-medium text-foreground">{f.label}</span>
<span className="block text-[13px] text-muted-foreground">{f.length}</span>
</span>
</li>
))}
{own.length % 2 === 1 && <li aria-hidden className="hidden bg-card sm:block" />}
</ul>
)}
{topic.takeaways?.length ? (
<>
<p className="mt-7 text-[13px] font-medium text-muted-foreground">{t.takeaways}</p>
<ul className="mt-3 grid gap-2.5">
{topic.takeaways.map((x, i) => (
<li key={x} className="flex gap-3 text-[15px] leading-relaxed text-foreground [animation:kitspk-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${150 + i * 70}ms` }}>
<Icon name="check" size={17} className="mt-[3px] text-primary" />
{x}
</li>
))}
</ul>
</>
) : null}
<div className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-3">
<Magnetic className="max-sm:flex max-sm:w-full">
<Button href={topic.href ?? bookHref ?? '#'} icon="arrow-right" className="max-sm:w-full">
{t.request}
</Button>
</Magnetic>
{topic.audience && (
<p className="text-[13.5px] text-muted-foreground">
{t.audience} {topic.audience}
</p>
)}
</div>
</div>
);
}
function Topics({ topics = [], formats = [], bookHref, t }: SpeakerProps & { t: Texts }) {
const [sel, setSel] = useState(topics[0]?.id ?? '');
const chosen = topics.find((x) => x.id === sel) ?? topics[0];
const base = useId();
const key = (e: React.KeyboardEvent) => {
const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const i = topics.findIndex((x) => x.id === sel);
const n = topics[(i + d + topics.length) % topics.length];
setSel(n.id);
(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-id="${n.id}"]`)?.focus();
};
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] lg:gap-16">
<div role="tablist" aria-orientation="vertical" aria-label={t.topics} onKeyDown={key} className="hidden border-t border-border lg:block">
{topics.map((x) => {
const on = x.id === chosen?.id;
return (
<button key={x.id} data-id={x.id} id={`${base}-tab-${x.id}`} type="button" role="tab" aria-selected={on} aria-controls={`${base}-panel`} tabIndex={on ? 0 : -1} onClick={() => setSel(x.id)} className={cn('group relative flex w-full items-start gap-4 border-b border-border py-5 pl-5 pr-2 text-left transition-colors', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span aria-hidden className={cn('absolute inset-y-3 left-0 w-[3px] origin-center rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-y-100' : 'scale-y-0')} />
<span className="min-w-0 flex-1">
<span className={cn('block text-[17px] font-semibold leading-snug tracking-[-0.01em] transition-colors', on ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground')}>{x.title}</span>
{x.hook && <span className="mt-1 block text-[13.5px] text-muted-foreground">{x.hook}</span>}
</span>
<Icon name="arrow-right" size={17} className={cn('mt-1 shrink-0 transition-[transform,opacity] duration-300', on ? 'translate-x-0 text-primary opacity-100' : '-translate-x-1 opacity-0 group-hover:translate-x-0 group-hover:opacity-60')} />
</button>
);
})}
</div>
{chosen && (
<div id={`${base}-panel`} role="tabpanel" aria-labelledby={`${base}-tab-${chosen.id}`} className="kit-card hidden p-8 lg:block lg:p-10">
<TopicDetail key={chosen.id} topic={chosen} formats={formats} t={t} bookHref={bookHref} />
</div>
)}
{/* Phones and tablets: every talk opens in place */}
<Stagger as="ul" step={70} className="grid gap-3 lg:hidden">
{topics.map((x) => {
const on = x.id === sel;
return (
<li key={x.id} className="kit-card overflow-hidden">
<h3>
<button type="button" aria-expanded={on} aria-controls={`${base}-m-${x.id}`} onClick={() => setSel(on ? '' : x.id)} className={cn('flex min-h-14 w-full items-center gap-4 px-5 py-4 text-left', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
<span className="min-w-0 flex-1">
<span className="block text-[16.5px] font-semibold leading-snug text-foreground">{x.title}</span>
{x.hook && !on && <span className="mt-0.5 block text-[13.5px] text-muted-foreground">{x.hook}</span>}
</span>
<Icon name="chevron-down" size={18} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', on && 'rotate-180')} />
</button>
</h3>
<div id={`${base}-m-${x.id}`} hidden={!on} className="border-t border-border px-5 pb-6 pt-5">
{on && <TopicDetail topic={x} formats={formats} t={t} bookHref={bookHref} compact />}
</div>
</li>
);
})}
</Stagger>
</div>
);
}
/* ------------------------------------------------------------------ */
/* reel */
/* ------------------------------------------------------------------ */
function Reel({ name, tagline, media, reelHref, reelLength, stats = [], hosts = [], hostsLabel, t }: SpeakerProps & { t: Texts }) {
const spot = useSpotlight<HTMLAnchorElement>();
return (
<div>
<a href={reelHref ?? '#'} onPointerMove={spot} className={cn('group kit-spotlight relative block overflow-hidden rounded-2xl border border-border bg-bezel shadow-[var(--shadow-lg)]', focusRing)} data-clip-ok>
<div className="relative aspect-[4/5] sm:aspect-[16/9] lg:aspect-[21/9]">
<Parallax speed={70} className={cn('absolute -inset-y-10 inset-x-0 transition-transform duration-1000 ease-[var(--ease-kit)] group-hover:scale-[1.02]', fit)}>
{media}
</Parallax>
<div className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.78),rgb(0_0_0/0.15)_55%,transparent)]" />
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-6 p-6 text-white sm:flex-row sm:items-end sm:justify-between sm:p-10">
<div className="max-w-[36rem]">
{name && <p className="text-[15px] font-medium text-white/80">{name}</p>}
{tagline && <p className="kit-rise mt-2 text-balance text-[28px] font-semibold leading-[1.1] tracking-[-0.025em] sm:text-[40px] lg:text-[48px]">{tagline}</p>}
</div>
<span className="inline-flex shrink-0 items-center gap-3">
<span className="relative flex size-16 items-center justify-center rounded-full bg-white text-black shadow-[var(--shadow-lg)] transition-transform duration-500 ease-[var(--ease-kit)] group-hover:scale-110">
<span aria-hidden className="absolute inset-0 rounded-full border-2 border-white [animation:kitspk-ring_2.4s_ease-out_infinite]" />
<Icon name="play" size={24} className="ml-1" />
</span>
<span className="text-[15px] font-medium">
{t.watch}
{reelLength && <span className="block text-[13px] font-normal tabular-nums text-white/75">{reelLength}</span>}
</span>
</span>
</div>
</div>
</a>
{stats.length > 0 && (
<dl className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
{stats.slice(0, 4).map((s, i) => (
<div key={s.label} className="flex flex-col-reverse justify-end bg-card p-5 sm:p-6">
<dt className="mt-2 text-[13.5px] leading-snug text-muted-foreground">{s.label}</dt>
<dd className="text-[32px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[40px]">
<NumberTicker value={s.value} delay={i * 120} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
{hosts.length > 0 && (
<div className="mt-12">
<p className="text-center text-[13px] font-medium text-muted-foreground">{hostsLabel ?? t.spokeAt}</p>
<Stagger as="ul" step={60} className="mt-6 grid grid-cols-2 items-center gap-x-6 gap-y-8 sm:grid-cols-3 lg:grid-cols-6">
{hosts.map((h, i) => (
<li key={i} className="flex h-10 items-center justify-center text-muted-foreground opacity-80 grayscale transition-[opacity,color,transform] duration-300 hover:-translate-y-0.5 hover:text-foreground hover:opacity-100 [&_svg]:max-h-7">
{h}
</li>
))}
</Stagger>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* past-talks */
/* ------------------------------------------------------------------ */
function PastTalks({ talks = [], formats = [], t }: SpeakerProps & { t: Texts }) {
const locale = useLocale();
const used = formats.filter((f) => talks.some((x) => x.format === f.id));
const [fmt, setFmt] = useState('all');
const shown = [...talks].filter((x) => fmt === 'all' || x.format === fmt).sort((a, b) => (a.date < b.date ? 1 : -1));
const years = [...new Set(shown.map((x) => x.date.slice(0, 4)))];
const label = (id: string) => formats.find((f) => f.id === id)?.label ?? id;
const [row, fade] = useScrollFade<HTMLDivElement>();
return (
<div>
<div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
<Segmented label={t.filter} value={fmt} onChange={setFmt} className="max-sm:flex max-sm:w-full [&>button]:max-sm:px-2" options={[{ id: 'all', label: <>{t.all}<span className="ml-1.5 tabular-nums opacity-60 max-sm:hidden">{talks.length}</span></> }, ...used.map((f) => ({ id: f.id, label: <>{f.label}<span className="ml-1.5 tabular-nums opacity-60 max-sm:hidden">{talks.filter((x) => x.format === f.id).length}</span></> }))]} />
</div>
<div key={fmt} className="mt-10">
{!shown.length && <p className="text-[15px] text-muted-foreground">{t.none}</p>}
{years.map((y, yi) => (
<section key={y} aria-labelledby={`spk-y-${y}`} className="grid grid-cols-1 gap-x-10 border-t border-border md:grid-cols-[120px_minmax(0,1fr)]">
<h3 id={`spk-y-${y}`} className="pt-5 text-[28px] font-semibold tabular-nums tracking-[-0.03em] text-foreground md:sticky md:top-6 md:self-start md:text-[34px] [animation:kitspk-in_.5s_var(--ease-kit)_both]" style={{ animationDelay: `${yi * 80}ms` }}>
{y}
</h3>
<ol>
{shown
.filter((x) => x.date.startsWith(y))
.map((x, i) => (
<li key={x.id} className="grid grid-cols-[64px_minmax(0,1fr)] gap-x-4 gap-y-2 border-b border-border py-5 last:border-0 sm:grid-cols-[72px_minmax(0,1fr)_auto] sm:items-center [animation:kitspk-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${yi * 80 + i * 50}ms` }}>
<span className="text-[13.5px] tabular-nums text-muted-foreground">{day(x.date, locale, { day: 'numeric', month: 'short' })}</span>
<span className="min-w-0">
<span className="block text-[16px] font-semibold leading-snug text-foreground">{x.title}</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13.5px] text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5 text-[12px] font-medium text-foreground">{label(x.format)}</span>
<span>
{x.event}
{x.city && <span className="whitespace-nowrap"> · {x.city}</span>}
</span>
</span>
</span>
{(x.recording || x.slides) && (
<span className="col-start-2 flex gap-2 sm:col-start-3 sm:grid sm:w-[236px] sm:grid-cols-2">
{x.recording && (
<a href={x.recording} className={cn('inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent max-sm:min-h-11', focusRing)}>
<Icon name="play-circle" size={15} className="text-primary" />
{t.recording}
</a>
)}
{!x.recording && <span aria-hidden className="max-sm:hidden" />}
{x.slides && (
<a href={x.slides} className={cn('inline-flex min-h-10 items-center justify-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent max-sm:min-h-11', focusRing)}>
<Icon name="presentation" size={15} className="text-muted-foreground" />
{t.slides}
</a>
)}
</span>
)}
</li>
))}
</ol>
</section>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* booking */
/* ------------------------------------------------------------------ */
function Booking({ formats = [], currency = 'EUR', onSubmit, t }: SpeakerProps & { t: Texts }) {
const locale = useLocale();
const base = useId();
const sizes = t.sizes.split('|');
const [format, setFormat] = useState(formats[0]?.id ?? '');
const [size, setSize] = useState('1');
const [setting, setSetting] = useState('onsite');
const [date, setDate] = useState('');
const [event, setEvent] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [sent, setSent] = useState(false);
const f = formats.find((x) => x.id === format);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const next: Record<string, string> = {};
if (!event.trim()) next.event = t.required;
if (!name.trim()) next.name = t.required;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = t.invalidEmail;
setErrors(next);
if (Object.keys(next).length) {
document.getElementById(`${base}-${Object.keys(next)[0]}`)?.focus();
return;
}
onSubmit?.({ format, size: sizes[Number(size)], setting, date, event, name, email, message });
setSent(true);
};
const field = 'h-11 w-full rounded-lg border bg-background px-3 text-[15px] text-foreground placeholder:text-subtle-foreground transition-[border-color,box-shadow] duration-200';
const err = (k: string) => (errors[k] ? 'border-destructive' : 'border-border-strong');
const lbl = 'mb-2 block text-[14px] font-medium text-foreground';
const dateText = date ? day(date, locale, { day: 'numeric', month: 'long', year: 'numeric' }) : t.open;
if (sent)
return (
<div role="status" className="kit-card mx-auto max-w-[560px] p-8 text-center [animation:kitspk-swap_.5s_var(--ease-kit)] sm:p-12">
<span className="mx-auto flex size-14 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success [animation:kitspk-pop_.5s_var(--ease-kit)]">
<Icon name="check" size={26} />
</span>
<h3 className="mt-5 text-h3 font-semibold text-foreground">{t.sentTitle}</h3>
<p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{date ? fill(t.sentText, { date: dateText }) : t.sentTextOpen}</p>
<Button variant="outline" className="mt-7" onClick={() => setSent(false)}>
{t.again}
</Button>
</div>
);
return (
<form onSubmit={submit} noValidate className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-14">
<div className="grid gap-7">
<fieldset>
<legend className={lbl}>{t.format}</legend>
<div role="radiogroup" className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{formats.map((x) => {
const on = x.id === format;
return (
<label key={x.id} className={cn('relative flex min-h-[88px] cursor-pointer flex-col justify-between rounded-xl border p-4 transition-[border-color,background-color,box-shadow,transform] duration-300 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring max-sm:flex-row max-sm:items-center max-sm:min-h-16', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] shadow-[var(--shadow-sm)]' : 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong')}>
<input type="radio" name={`${base}-format`} value={x.id} checked={on} onChange={() => setFormat(x.id)} className="sr-only" />
<Icon name={x.icon ?? 'mic'} size={20} className={cn('transition-colors max-sm:order-2', on ? 'text-primary' : 'text-muted-foreground')} />
<span className="max-sm:order-1">
<span className="block text-[15px] font-semibold text-foreground">{x.label}</span>
<span className="block text-[13px] text-muted-foreground">{x.length}</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label htmlFor={`${base}-date`} className={lbl}>
{t.date}
</label>
<input id={`${base}-date`} type="date" value={date} onChange={(e) => setDate(e.target.value)} className={cn(field, 'border-border-strong [color-scheme:light] dark:[color-scheme:dark]', focusRing)} />
</div>
<div>
<span id={`${base}-setting-l`} className={lbl}>
{t.setting}
</span>
<Segmented label={t.setting} value={setting} onChange={setSetting} className="flex w-full [&>button]:flex-1" options={[{ id: 'onsite', label: t.onsite }, { id: 'online', label: t.online }]} />
</div>
</div>
<fieldset>
<legend className={lbl}>{t.size}</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{sizes.map((s, i) => {
const on = String(i) === size;
return (
<label key={s} className={cn('flex min-h-11 cursor-pointer items-center justify-center rounded-lg border px-2 text-center text-[13.5px] font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] font-semibold text-foreground' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground')}>
<input type="radio" name={`${base}-size`} value={i} checked={on} onChange={() => setSize(String(i))} className="sr-only" />
{s}
</label>
);
})}
</div>
</fieldset>
<div>
<label htmlFor={`${base}-event`} className={lbl}>
{t.event}
</label>
<input id={`${base}-event`} value={event} onChange={(e) => setEvent(e.target.value)} placeholder={t.eventPh} aria-invalid={!!errors.event} aria-describedby={errors.event ? `${base}-event-e` : undefined} className={cn(field, err('event'), focusRing)} />
{errors.event && (
<p id={`${base}-event-e`} className="mt-1.5 text-[13px] text-destructive">
{errors.event}
</p>
)}
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{(['name', 'email'] as const).map((k) => (
<div key={k}>
<label htmlFor={`${base}-${k}`} className={lbl}>
{k === 'name' ? t.name : t.email}
</label>
<input id={`${base}-${k}`} type={k === 'email' ? 'email' : 'text'} autoComplete={k} value={k === 'name' ? name : email} onChange={(e) => (k === 'name' ? setName : setEmail)(e.target.value)} placeholder={k === 'email' ? t.emailPh : undefined} aria-invalid={!!errors[k]} aria-describedby={errors[k] ? `${base}-${k}-e` : undefined} className={cn(field, err(k), focusRing)} />
{errors[k] && (
<p id={`${base}-${k}-e`} className="mt-1.5 text-[13px] text-destructive">
{errors[k]}
</p>
)}
</div>
))}
</div>
<div>
<label htmlFor={`${base}-msg`} className={lbl}>
{t.message}
</label>
<textarea id={`${base}-msg`} rows={4} value={message} onChange={(e) => setMessage(e.target.value)} placeholder={t.messagePh} className={cn('w-full resize-y rounded-lg border border-border-strong bg-background px-3 py-2.5 text-[15px] text-foreground placeholder:text-subtle-foreground', focusRing)} />
</div>
</div>
<aside className="lg:sticky lg:top-8 lg:self-start">
<div className="kit-card overflow-hidden">
<p className="border-b border-border px-6 py-4 text-[13px] font-medium text-muted-foreground">{t.summary}</p>
<dl className="divide-y divide-border px-6">
{[
[t.format, f ? `${f.label}, ${f.length}` : t.open],
[t.date, dateText],
[t.size, sizes[Number(size)]],
[t.setting, setting === 'onsite' ? t.onsite : t.online],
].map(([k, v]) => (
<div key={k} className="flex items-baseline justify-between gap-4 py-3.5">
<dt className="text-[13.5px] text-muted-foreground">{k}</dt>
<dd key={v} className="text-right text-[14.5px] font-medium text-foreground [animation:kitspk-swap_.4s_var(--ease-kit)]">
{v}
</dd>
</div>
))}
</dl>
{f?.fee !== undefined && (
<div className="border-t border-border bg-surface px-6 py-5">
<p className="text-[13px] text-muted-foreground">{t.from}</p>
<p key={`${format}-${setting}`} className="mt-1 text-[30px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground [animation:kitspk-swap_.45s_var(--ease-kit)]">
{money(setting === 'online' ? Math.round(f.fee * 0.7) : f.fee, currency, locale)}
</p>
{setting === 'onsite' && <p className="mt-1.5 text-[13px] text-muted-foreground">{t.travel}</p>}
</div>
)}
<div className="border-t border-border p-6">
<Button type="submit" icon="send" className="w-full">
{t.send}
</Button>
</div>
</div>
</aside>
</form>
);
}
/* ------------------------------------------------------------------ */
/* one-sheet */
/* ------------------------------------------------------------------ */
function OneSheet({ name, bios, intro, photos = [], facts = [], t }: SpeakerProps & { t: Texts }) {
const [len, setLen] = useState<'short' | 'medium' | 'long'>('medium');
const [done, copy] = useCopy();
const text = bios?.[len] ?? '';
const words = text.trim().split(/\s+/).filter(Boolean).length;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="grid content-start gap-6">
{bios && (
<Reveal className="kit-card p-6 sm:p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<h3 className="text-[17px] font-semibold text-foreground">{t.bio}</h3>
<Segmented label={t.lengthLabel} value={len} onChange={(v) => setLen(v as typeof len)} options={[{ id: 'short', label: t.short }, { id: 'medium', label: t.medium }, { id: 'long', label: t.long }]} className="max-sm:w-full" />
</div>
<p key={len} className="mt-5 whitespace-pre-line text-pretty text-[15.5px] leading-relaxed text-foreground [animation:kitspk-swap_.45s_var(--ease-kit)]">
{text}
</p>
<div className="mt-6 flex items-center justify-between gap-4 border-t border-border pt-4">
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.words, { n: words })}</span>
<CopyButton on={done === 'bio'} onClick={() => copy('bio', text)} t={t} />
</div>
</Reveal>
)}
{intro && (
<Reveal delay={80} className="kit-card relative overflow-hidden p-6 sm:p-8">
<Icon name="quote" size={40} className="absolute right-6 top-6 text-[color-mix(in_oklab,var(--primary)_30%,transparent)]" />
<h3 className="text-[17px] font-semibold text-foreground">{t.introTitle}</h3>
<p className="mt-4 max-w-[38rem] text-pretty text-[17px] italic leading-relaxed text-foreground">{intro}</p>
<div className="mt-6 flex justify-end border-t border-border pt-4">
<CopyButton on={done === 'intro'} onClick={() => copy('intro', intro)} t={t} />
</div>
</Reveal>
)}
{facts.length > 0 && (
<Reveal delay={120} className="kit-card p-6 sm:p-8">
<h3 className="text-[17px] font-semibold text-foreground">{t.facts}</h3>
<dl className="mt-3 divide-y divide-border">
{facts.map((f) => (
<div key={f.label} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-4 py-3">
<dt className="text-[13.5px] text-muted-foreground">{f.label}</dt>
<dd className="text-[14px] text-foreground">{f.value}</dd>
</div>
))}
</dl>
{name && <p className="sr-only">{name}</p>}
</Reveal>
)}
</div>
<div className="grid content-start gap-6">
{photos.length > 0 && (
<Reveal delay={120} className="kit-card p-6">
<h3 className="text-[17px] font-semibold text-foreground">{t.photos}</h3>
<ul className="mt-4 grid grid-cols-2 gap-3">
{photos.map((p, i) => (
<li key={p.label} className={cn(i === 0 && photos.length % 2 === 1 && 'col-span-2')}>
<a href={p.href} download className={cn('group block', focusRing, 'rounded-lg')} aria-label={`${t.download} ${p.label}`}>
<span className={cn('relative block overflow-hidden rounded-lg bg-muted', i === 0 && photos.length % 2 === 1 ? 'aspect-[4/3]' : 'aspect-[4/5]')} data-clip-ok>
<span className={cn('absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-105', fit)}>{p.image}</span>
<span className="absolute inset-x-2 bottom-2 flex items-center justify-between rounded-md bg-background/85 px-2 py-1.5 text-[12px] font-medium text-foreground opacity-100 backdrop-blur transition-[opacity,transform] duration-300 sm:translate-y-1 sm:opacity-0 sm:group-hover:translate-y-0 sm:group-hover:opacity-100 sm:group-focus-visible:translate-y-0 sm:group-focus-visible:opacity-100">
{t.download}
<Icon name="download" size={14} />
</span>
</span>
<span className="mt-2 block text-[13.5px] font-medium text-foreground">{p.label}</span>
{p.size && <span className="block text-[12.5px] text-muted-foreground">{p.size}</span>}
</a>
</li>
))}
</ul>
</Reveal>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Speaker */
/* ------------------------------------------------------------------ */
/**
* Sections for a speaker page.
* topics talks with abstract, formats and takeaways
* reel stage picture, showreel, numbers and hosts
* past-talks talks by year, filtered by format
* booking an enquiry with a summary and the fee from
* one-sheet bios to copy, the intro for the host, press photos and facts
*/
export function Speaker(props: SpeakerProps) {
const { variant = 'topics', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const body = (() => {
switch (variant) {
case 'reel':
return <Reel {...props} t={t} />;
case 'past-talks':
return <PastTalks {...props} t={t} />;
case 'booking':
return <Booking {...props} t={t} />;
case 'one-sheet':
return <OneSheet {...props} t={t} />;
default:
return <Topics {...props} t={t} />;
}
})();
return (
<Section className={className} background={variant === 'one-sheet' ? 'surface' : 'default'}>
<SpkMotion />
{title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} align={variant === 'reel' ? 'split' : 'left'} /> : null}
{body}
</Section>
);
}Frequent questions
How do I install Speaker?
With aiw add @ai-website/speaker. The command puts components/ui/speaker.tsx into your project, the code is yours from then on. It brings along button, icon, motion, number-ticker, reveal and section. It needs no further npm packages.
Which variants does Speaker have?
5 variants, Topics, Showreel, Past talks, Booking and One-sheet. You pick one with the variant prop, for example variant="reel".
Can I translate the texts of Speaker?
Yes. Speaker carries 49 texts of its own in English and German, such as “Talks”, “Formats” and “What people take home”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Speaker?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Speaker with aiw_components and install it with aiw_add @ai-website/speaker. Name the variant in your prompt, for example variant="topics", and the assistant writes your content straight into the props.
Can I adjust Speaker without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Speaker code to copy.