Zum Inhalt springen
UI Kit
Accent

Speaker

Für Keynote-Speaker, Trainerinnen und Fachleute auf der Bühne. Vorträge mit Formaten und Erkenntnissen, Showreel mit Zahlen und Veranstaltern, frühere Auftritte nach Jahr, Buchungsanfrage und ein One-Sheet für Veranstalter.

Eine von 2 Components in Speaker und Media Kits

5 Variants

Variants

Themen

Vorträge als Liste, der gewählte daneben mit Inhalt, Formaten und dem, was die Leute mitnehmen. Eine Markierung wächst am aktiven Vortrag, Pfeiltasten wechseln. Auf dem Telefon ein Akkordeon.

Im Studio öffnen

Showreel

Ein breites Bühnenbild, das beim Scrollen wandert, der Play-Knopf pulsiert. Darunter Zahlen, die hochzählen, und die Logos früherer Veranstalter.

Im Studio öffnen

Frühere Auftritte

Jeder Auftritt nach Jahr, das Jahr bleibt stehen, während die Auftritte scrollen. Ein gleitender Filter nach Format mit Zahlen, Aufzeichnungen und Folien, wo es sie gibt.

Im Studio öffnen

Buchung

Eine Anfrage mit Formatkarten, Datum, Rahmen, Publikumsgröße und Anlass. Die Zusammenfassung daneben folgt jeder Wahl, das Honorar wechselt weich.

Im Studio öffnen

One-Sheet

Alles, was Veranstalter brauchen. Die Bio in drei Längen mit Wortzahl und Kopieren, die Anmoderation, Pressefotos zum Herunterladen und die Fakten.

Im Studio öffnen

Details

Was Speaker mitbringt, aus Registry und Quellcode gelesen.

Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/speaker.tsx
Bringt mit
utilskitbuttoniconmotionnumber-tickerrevealsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 49 eigene Texte

Props

Die Schnittstelle SpeakerProps aus components/ui/speaker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SpeakerVariant

    topicsreelpast-talksbookingone-sheet

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • nameoptional
    string
  • taglineoptional
    string

    e.g. "Keynote speaker on work and attention"

  • formatsoptional
    SpeakerFormat[]
  • topicsoptional
    SpeakerTopic[]
  • talksoptional
    SpeakerTalk[]
  • mediaoptional
    React.ReactNode

    reel: the stage picture and the showreel

  • reelHrefoptional
    string
  • reelLengthoptional
    string
  • statsoptional
    { value: number; suffix?: string; label: string }[]
  • hostsoptional
    React.ReactNode[]

    reel: logos of hosts so far

  • hostsLabeloptional
    string
  • biosoptional
    { short: string; medium: string; long: string }

    one-sheet

  • introoptional
    string
  • photosoptional
    { label: string; size?: string; href: string; image?: React.ReactNode }[]
  • factsoptional
    { label: string; value: string }[]
  • currencyoptional
    string
  • bookHrefoptional
    string
  • onSubmitoptional
    (data: Record<string, string>) => void

Eingebaute Texte

Diese Texte schreibt Speaker selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 49 Texte anzeigen
topicsEnglisch TalksDeutsch Vorträge
formatsEnglisch FormatsDeutsch Formate
takeawaysEnglisch What people take homeDeutsch Was die Leute mitnehmen
audienceEnglisch ForDeutsch Für
requestEnglisch Request this talkDeutsch Diesen Vortrag anfragen
watchEnglisch Watch the showreelDeutsch Showreel ansehen
spokeAtEnglisch Spoke atDeutsch Auf der Bühne bei
allEnglisch AllDeutsch Alle
filterEnglisch Filter by formatDeutsch Nach Format filtern
recordingEnglisch RecordingDeutsch Aufzeichnung
slidesEnglisch SlidesDeutsch Folien
noneEnglisch No talks in this format yet.Deutsch In diesem Format gibt es noch keine Vorträge.
dateEnglisch Event dateDeutsch Datum der Veranstaltung
formatEnglisch FormatDeutsch Format
sizeEnglisch AudienceDeutsch Publikum
settingEnglisch SettingDeutsch Rahmen
onsiteEnglisch On siteDeutsch Vor Ort
onlineEnglisch OnlineDeutsch Online
sizesEnglisch Up to 100|100 to 500|500 to 2,000|More than 2,000Deutsch Bis 100|100 bis 500|500 bis 2.000|Mehr als 2.000
eventEnglisch Event and organisationDeutsch Veranstaltung und Organisation
eventPhEnglisch Annual meeting, Atlas HealthDeutsch Jahrestagung, Atlas Health
nameEnglisch Your nameDeutsch Dein Name
emailEnglisch EmailDeutsch E-Mail
emailPhEnglisch you@example.comDeutsch du@beispiel.de
messageEnglisch What is the occasion?Deutsch Worum geht es?
messagePhEnglisch Who is in the room and what should change for them?Deutsch Wer sitzt im Saal und was soll sich für diese Menschen ändern?
sendEnglisch Send enquiryDeutsch Anfrage senden
requiredEnglisch Please fill in this field.Deutsch Bitte fülle dieses Feld aus.
invalidEmailEnglisch Please enter an email address like name@example.com.Deutsch Bitte gib eine E-Mail-Adresse wie name@beispiel.de ein.
sentTitleEnglisch Enquiry receivedDeutsch Anfrage ist angekommen
sentTextEnglisch You get an answer within two working days, with availability for {date} and a proposal.Deutsch Du bekommst innerhalb von zwei Werktagen eine Antwort, mit der Verfügbarkeit für {date} und einem Vorschlag.
sentTextOpenEnglisch You get an answer within two working days, with open dates and a proposal.Deutsch Du bekommst innerhalb von zwei Werktagen eine Antwort, mit freien Terminen und einem Vorschlag.
againEnglisch Send another enquiryDeutsch Weitere Anfrage senden
summaryEnglisch Your enquiryDeutsch Deine Anfrage
fromEnglisch Fee fromDeutsch Honorar ab
travelEnglisch plus travelDeutsch zzgl. Reise
openEnglisch openDeutsch offen
bioEnglisch BioDeutsch Bio
shortEnglisch ShortDeutsch Kurz
mediumEnglisch MediumDeutsch Mittel
longEnglisch LongDeutsch Lang
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
wordsEnglisch {n} wordsDeutsch {n} Wörter
introTitleEnglisch Intro for the hostDeutsch Anmoderation
photosEnglisch Press photosDeutsch Pressefotos
downloadEnglisch DownloadDeutsch Herunterladen
factsEnglisch FactsDeutsch Fakten
lengthLabelEnglisch Bio lengthDeutsch Länge der Bio

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Speaker reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
  • Setzt aria-checked, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live, aria-orientation und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 828 Zeilen
components/ui/speaker.tsx
'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>
  );
}

Häufige Fragen

Wie installiere ich Speaker?

Mit aiw add @ai-website/speaker. Der Befehl legt die Datei components/ui/speaker.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, number-ticker, reveal und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Speaker?

5 Varianten, Themen, Showreel, Frühere Auftritte, Buchung und One-Sheet. Du wählst sie mit der Prop variant, zum Beispiel variant="reel".

Kann ich die Texte von Speaker übersetzen?

Ja. Speaker bringt 49 eigene Texte auf Deutsch und Englisch mit, etwa „Vorträge“, „Formate“ und „Was die Leute mitnehmen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Speaker einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Speaker mit aiw_components und installieren sie mit aiw_add @ai-website/speaker. Nenn im Prompt die Variante, etwa variant="topics", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Speaker ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Speaker-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert