Zum Inhalt springen
UI Kit
Accent

Interview

Interviews, gesetzt, damit man hört, wer spricht. Frage und Antwort mit Initialen am Rand, ein Porträt neben dem Gespräch, eine Runde, in der eine Stimme allein stehen kann, schnelle Fragen als Karten und die stärksten Zitate nacheinander.

Eine von 2 Components in Autoren und Interviews

5 Variants

Variants

Frage und Antwort

Eine Spalte, die Initialen der Sprechenden am Rand, die Frage fett. Ein Satz der Antwort kehrt als Zitat wieder.

Im Studio öffnen

Porträt

Ein großes Porträt, das beim Scrollen gleitet, und die Fakten zur Person bleiben neben dem Gespräch, die Fragen als Zwischentitel.

Im Studio öffnen

Gesprächsrunde

Mehrere Stimmen, jede mit Gesicht und Namen. Eine Stimme wählen, und die anderen treten zurück, auf dem Telefon liegen die Stimmen in einer Reihe zum Wischen.

Im Studio öffnen

Schnelle Fragen

Kurze Fragen als Karten. Ein Druck macht aus der Karte die Antwort, ein Knopf zeigt alle.

Im Studio öffnen

Höhepunkte

Die stärksten Sätze nacheinander, Wort für Wort. Eine Fortschrittslinie läuft, solange die Section sichtbar ist, Pfeile und Pause bleiben in deiner Hand.

Im Studio öffnen

Details

Was Interview mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/interview.tsx
Bringt mit
utilskiticonrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 11 eigene Texte

Props

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

  • variantoptional
    InterviewVariant

    classicportraitround-tablequick-firehighlights

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • hostoptional
    InterviewPerson

    Who asks

  • peopleoptional
    InterviewPerson[]

    Who answers; round-table: everyone at the table

  • itemsoptional
    InterviewItem[]
  • mediaoptional
    React.ReactNode

    portrait: the large picture

  • factsoptional
    { label: string; value: string }[]

    portrait: facts, e.g. [{ label: 'Studio', value: 'Graz, since 2014' }]

  • quotesoptional
    string[]

    highlights: the sentences

  • actionoptional
    { label: string; href: string }

    highlights: link to the whole conversation

  • dateoptional
    string

    As shown, e.g. Oct 21, 2026

Eingebaute Texte

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

Alle 11 Texte anzeigen
interviewEnglisch InterviewDeutsch Interview
byEnglisch Questions by {name}Deutsch Fragen von {name}
allEnglisch All voicesDeutsch Alle Stimmen
showAnswerEnglisch Show answerDeutsch Antwort zeigen
revealEnglisch Reveal all answersDeutsch Alle Antworten zeigen
hideEnglisch Hide all answersDeutsch Alle Antworten verbergen
quoteEnglisch Quote {a} of {b}Deutsch Zitat {a} von {b}
previousEnglisch Previous quoteDeutsch Vorheriges Zitat
nextEnglisch Next quoteDeutsch Nächstes Zitat
pauseEnglisch PauseDeutsch Anhalten
playEnglisch PlayDeutsch Abspielen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-expanded, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle group.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 532 Zeilen
components/ui/interview.tsx
'use client';

import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Parallax, Stagger, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';

/*
 * Interviews, set so that one hears who speaks. Questions and answers are
 * plain data; a speaker is named once and recognised by initials after that.
 *
 *   classic      question and answer in one column, the initials in the margin,
 *                a pull quote between the answers
 *   portrait     a large portrait and facts that stay beside the conversation
 *   round-table  several voices; show one voice alone, the others step back
 *   quick-fire   short questions as cards, the answer turns up on press
 *   highlights   the strongest sentences one by one with a progress line,
 *                then the way to the whole conversation
 */

export type InterviewVariant = 'classic' | 'portrait' | 'round-table' | 'quick-fire' | 'highlights';

export interface InterviewPerson {
  name: string;
  role?: string;
  avatar?: React.ReactNode;
  /** Shown in the margin, default the initials */
  short?: string;
}

export interface InterviewItem {
  /** The question; empty for a statement in a round table */
  q?: string;
  /** The answer in paragraphs */
  a: string[];
  /** round-table: index into `people`, who answers */
  by?: number;
  /** A sentence of the answer set as a pull quote after it */
  pull?: string;
}

export interface InterviewProps {
  variant?: InterviewVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Who asks */
  host?: InterviewPerson;
  /** Who answers; round-table: everyone at the table */
  people?: InterviewPerson[];
  items?: InterviewItem[];
  /** portrait: the large picture */
  media?: React.ReactNode;
  /** portrait: facts, e.g. [{ label: 'Studio', value: 'Graz, since 2014' }] */
  facts?: { label: string; value: string }[];
  /** highlights: the sentences */
  quotes?: string[];
  /** highlights: link to the whole conversation */
  action?: { label: string; href: string };
  /** As shown, e.g. Oct 21, 2026 */
  date?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    interview: 'Interview',
    by: 'Questions by {name}',
    all: 'All voices',
    showAnswer: 'Show answer',
    reveal: 'Reveal all answers',
    hide: 'Hide all answers',
    quote: 'Quote {a} of {b}',
    previous: 'Previous quote',
    next: 'Next quote',
    pause: 'Pause',
    play: 'Play',
  },
  de: {
    interview: 'Interview',
    by: 'Fragen von {name}',
    all: 'Alle Stimmen',
    showAnswer: 'Antwort zeigen',
    reveal: 'Alle Antworten zeigen',
    hide: 'Alle Antworten verbergen',
    quote: 'Zitat {a} von {b}',
    previous: 'Vorheriges Zitat',
    next: 'Nächstes Zitat',
    pause: 'Anhalten',
    play: 'Abspielen',
  },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

const initials = (p?: InterviewPerson) =>
  p?.short ??
  (p?.name ?? '')
    .split(' ')
    .filter((w) => /^[A-ZÄÖÜ]/.test(w))
    .map((w) => w[0])
    .slice(0, 2)
    .join('');

const ANSWER = 'text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))] sm:text-[18px]';
const QUESTION = 'text-pretty text-[17px] font-semibold leading-[1.55] text-foreground sm:text-[18px]';

function Face({ p, className }: { p?: InterviewPerson; className?: string }) {
  return (
    <span data-clip-ok className={cn('grid shrink-0 place-items-center overflow-hidden rounded-full border border-border bg-muted text-[0.38em] font-semibold text-muted-foreground [&>*]:size-full', className)}>
      {p?.avatar ?? initials(p)}
    </span>
  );
}

function PullQuote({ text }: { text: string }) {
  return (
    <Reveal effect="rise" className="my-12 sm:my-14">
      <figure className="border-l-2 border-primary pl-6 sm:pl-8">
        <blockquote className="text-balance text-[26px] font-semibold leading-[1.25] tracking-[-0.02em] text-foreground sm:text-[32px]">{text}</blockquote>
      </figure>
    </Reveal>
  );
}

function Head({ eyebrow, title, description, date, people, host, t, center }: InterviewProps & { t: Texts; center?: boolean }) {
  const guest = people?.[0];
  return (
    <header className={cn('max-w-[44rem]', center && 'mx-auto text-center')}>
      <Eyebrow className="kit-rise">{eyebrow ?? t.interview}</Eyebrow>
      {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
      {description && <p className="kit-rise mt-5 text-pretty text-[18px] leading-[1.6] text-muted-foreground sm:text-[20px]" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
      {(guest || date) && (
        <div className={cn('kit-rise mt-7 flex items-center gap-3 border-t border-border pt-6 text-[14px]', center && 'justify-center')} style={{ '--kit-delay': '180ms' } as React.CSSProperties}>
          {guest && <Face p={guest} className="size-11" />}
          <div className={cn('leading-snug', center && 'text-left')}>
            {guest && (
              <p className="font-medium text-foreground">
                {guest.name}
                {guest.role && <span className="font-normal text-muted-foreground">, {guest.role}</span>}
              </p>
            )}
            <p className="mt-0.5 text-muted-foreground">
              {[host ? t.by.replace('{name}', host.name) : null, date].filter(Boolean).join(' · ')}
            </p>
          </div>
        </div>
      )}
    </header>
  );
}

/** One exchange: the speaker's initials in the margin on wide screens, above the text on phones */
function Exchange({ item, host, guest, i }: { item: InterviewItem; host?: InterviewPerson; guest?: InterviewPerson; i: number }) {
  return (
    <div className={cn(i > 0 && 'mt-10')}>
      {item.q && (
        <Reveal effect="fade" className="grid grid-cols-[minmax(0,1fr)] sm:grid-cols-[3.5rem_minmax(0,1fr)]">
          <span className="mb-1 text-[12.5px] font-semibold tracking-[0.04em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] sm:mb-0 sm:pt-[3px]" aria-hidden>
            {initials(host)}
          </span>
          <p className={QUESTION}>
            <span className="sr-only">{host?.name}: </span>
            {item.q}
          </p>
        </Reveal>
      )}
      <div className="mt-4 grid grid-cols-[minmax(0,1fr)] sm:grid-cols-[3.5rem_minmax(0,1fr)]">
        <span className="mb-1 text-[12.5px] font-semibold tracking-[0.04em] text-muted-foreground sm:mb-0 sm:pt-[4px]" aria-hidden>
          {initials(guest)}
        </span>
        <div className="space-y-5">
          {item.a.map((para, j) => (
            <p key={j} className={ANSWER}>
              {j === 0 && <span className="sr-only">{guest?.name}: </span>}
              {para}
            </p>
          ))}
        </div>
      </div>
      {item.pull && (
        <div className="sm:pl-14">
          <PullQuote text={item.pull} />
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* classic                                                             */
/* ------------------------------------------------------------------ */

function Classic(p: InterviewProps & { t: Texts }) {
  const guest = p.people?.[0];
  return (
    <article className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[44rem]">
          <Head {...p} />
          <div className="mt-12 sm:mt-14">
            {(p.items ?? []).map((it, i) => (
              <Exchange key={i} item={it} host={p.host} guest={guest} i={i} />
            ))}
          </div>
        </div>
      </div>
    </article>
  );
}

/* ------------------------------------------------------------------ */
/* portrait                                                            */
/* ------------------------------------------------------------------ */

function Portrait(p: InterviewProps & { t: Texts }) {
  const guest = p.people?.[0];
  return (
    <article className={cn('overflow-x-clip py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 md:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] md:gap-12 lg:grid-cols-[minmax(0,24rem)_minmax(0,40rem)] lg:justify-center lg:gap-20">
          <aside className="md:sticky md:top-10 md:self-start">
            <Reveal effect="scale">
              <div data-clip-ok className="relative aspect-[5/4] overflow-hidden rounded-2xl md:aspect-[4/5] border border-border bg-muted shadow-[var(--shadow-lg)]">
                <Parallax speed={40} className="size-full scale-[1.12] [&>*]:size-full">
                  {p.media}
                </Parallax>
                <div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(transparent,rgb(0_0_0/0.62))] p-5 pt-16 text-white">
                  <p className="text-[19px] font-semibold leading-tight">{guest?.name}</p>
                  {guest?.role && <p className="mt-0.5 text-[14px] opacity-85">{guest.role}</p>}
                </div>
              </div>
            </Reveal>
            {p.facts && p.facts.length > 0 && (
              <dl className="mt-6 divide-y divide-border border-y border-border">
                {p.facts.map((f, i) => (
                  <Reveal key={i} effect="fade" delay={i * 60} className="flex items-baseline justify-between gap-4 py-3">
                    <dt className="text-[13.5px] text-muted-foreground">{f.label}</dt>
                    <dd className="text-right text-[14.5px] font-medium text-foreground">{f.value}</dd>
                  </Reveal>
                ))}
              </dl>
            )}
          </aside>
          <div className="min-w-0">
            <header>
              <Eyebrow className="kit-rise">{p.eyebrow ?? p.t.interview}</Eyebrow>
              {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
              {p.description && <p className="kit-rise mt-5 text-pretty text-[18px] leading-[1.6] text-muted-foreground sm:text-[20px]" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
              <p className="kit-rise mt-5 text-[14px] text-muted-foreground" style={{ '--kit-delay': '160ms' } as React.CSSProperties}>
                {[p.host ? p.t.by.replace('{name}', p.host.name) : null, p.date].filter(Boolean).join(' · ')}
              </p>
            </header>
            <div className="mt-12 border-t border-border pt-10">
              {(p.items ?? []).map((it, i) => (
                <div key={i} className={cn(i > 0 && 'mt-12')}>
                  {it.q && (
                    <Reveal effect="rise">
                      <h3 className="text-balance text-[21px] font-semibold leading-[1.35] tracking-[-0.01em] text-foreground sm:text-[23px]">{it.q}</h3>
                    </Reveal>
                  )}
                  <div className="mt-4 space-y-5">
                    {it.a.map((para, j) => (
                      <p key={j} className={ANSWER}>
                        {para}
                      </p>
                    ))}
                  </div>
                  {it.pull && <PullQuote text={it.pull} />}
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </article>
  );
}

/* ------------------------------------------------------------------ */
/* round-table                                                         */
/* ------------------------------------------------------------------ */

function RoundTable(p: InterviewProps & { t: Texts }) {
  const { t } = p;
  const people = p.people ?? [];
  const [only, setOnly] = useState<number | null>(null);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const items = p.items ?? [];
  return (
    <article className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,16rem)_minmax(0,42rem)] lg:justify-center lg:gap-16">
          <div className="lg:col-start-2">
            <Head {...p} people={undefined} />
          </div>
          {/* Who is at the table, a filter at the same time */}
          <aside className="lg:col-start-1 lg:row-start-2">
            <div className="lg:sticky lg:top-10">
              <div ref={rowRef} role="group" aria-label={t.all} className={cn('kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 lg:mx-0 lg:flex-col lg:overflow-visible lg:px-0', fade)}>
                <button type="button" aria-pressed={only === null} onClick={() => setOnly(null)} className={cn('flex min-h-11 shrink-0 items-center gap-3 rounded-xl border px-3 text-left text-[14px] font-medium transition-[background-color,border-color,color] duration-300', only === null ? 'border-border-strong bg-card text-foreground shadow-[var(--shadow-sm)]' : 'border-border text-muted-foreground hover:text-foreground')}>
                  <span className="flex -space-x-2">
                    {people.slice(0, 3).map((x, i) => (
                      <Face key={i} p={x} className="size-7 ring-2 ring-background" />
                    ))}
                  </span>
                  {t.all}
                </button>
                {people.map((x, i) => (
                  <button key={i} type="button" aria-pressed={only === i} onClick={() => setOnly(only === i ? null : i)} className={cn('flex min-h-11 shrink-0 items-center gap-3 rounded-xl border px-3 py-2 text-left transition-[background-color,border-color,color] duration-300', only === i ? 'border-border-strong bg-card shadow-[var(--shadow-sm)]' : 'border-border hover:border-border-strong')}>
                    <Face p={x} className="size-9" />
                    <span className="min-w-0">
                      <span className="block whitespace-nowrap text-[14px] font-medium text-foreground">{x.name}</span>
                      {x.role && <span className="block whitespace-nowrap text-[12.5px] text-muted-foreground">{x.role}</span>}
                    </span>
                  </button>
                ))}
              </div>
            </div>
          </aside>
          <div className="min-w-0 lg:col-start-2 lg:row-start-2">
            {items.map((it, i) => {
              const who = it.by !== undefined ? people[it.by] : undefined;
              const dim = only !== null && it.q === undefined && it.by !== only;
              return (
                <div key={i} className={cn('transition-opacity duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', i > 0 && (it.q ? 'mt-12' : 'mt-7'), dim && 'opacity-25')}>
                  {it.q ? (
                    <Reveal effect="fade">
                      <p className={cn(QUESTION, 'border-t border-border pt-8')}>
                        <span className="mr-2 text-[12.5px] font-semibold tracking-[0.04em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{initials(p.host)}</span>
                        <span className="sr-only">{p.host?.name}: </span>
                        {it.q}
                      </p>
                    </Reveal>
                  ) : null}
                  {it.a.length > 0 && (
                    <div className={cn('flex gap-4', it.q && 'mt-5')}>
                      <Face p={who} className="mt-1 size-9" />
                      <div className="min-w-0 space-y-4">
                        <p className="text-[13.5px] font-semibold text-foreground">{who?.name}</p>
                        {it.a.map((para, j) => (
                          <p key={j} className={cn(ANSWER, '-mt-2 first-of-type:mt-0')}>
                            {para}
                          </p>
                        ))}
                      </div>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </article>
  );
}

/* ------------------------------------------------------------------ */
/* quick-fire                                                          */
/* ------------------------------------------------------------------ */

function QuickFire(p: InterviewProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const items = p.items ?? [];
  const [open, setOpen] = useState<Set<number>>(new Set());
  const all = open.size === items.length && items.length > 0;
  const toggle = (i: number) =>
    setOpen((s) => {
      const n = new Set(s);
      if (n.has(i)) n.delete(i);
      else n.add(i);
      return n;
    });
  const guest = p.people?.[0];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-[40rem]">
            <Eyebrow className="kit-rise">{p.eyebrow ?? t.interview}</Eyebrow>
            {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
            {p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
          </div>
          <button type="button" onClick={() => setOpen(all ? new Set() : new Set(items.map((_, i) => i)))} className="kit-rise inline-flex min-h-11 items-center gap-2 self-start rounded-lg border border-border px-4 text-[14px] font-medium text-foreground transition-colors hover:bg-accent lg:self-end">
            <Icon name={all ? 'eye-off' : 'eye'} size={16} />
            {all ? t.hide : t.reveal}
          </button>
        </div>
        <Stagger as="ul" step={60} className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-3">
          {items.map((it, i) => {
            const on = open.has(i);
            return (
              <li key={i}>
                <button
                  type="button"
                  aria-expanded={on}
                  aria-controls={`${uid}-a${i}`}
                  onClick={() => toggle(i)}
                  className={cn('group/c relative flex h-full min-h-[9rem] w-full flex-col rounded-2xl sm:min-h-[11rem] border p-5 text-left transition-[background-color,border-color,box-shadow,transform] duration-500 ease-[var(--ease-kit)] hover:-translate-y-0.5 sm:p-6', on ? 'border-transparent bg-primary text-primary-foreground shadow-[var(--shadow-lg)]' : 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] hover:border-border-strong')}
                >
                  <span className={cn('text-[12.5px] font-semibold tabular-nums transition-colors', on ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{String(i + 1).padStart(2, '0')}</span>
                  <span className={cn('mt-3 block text-balance text-[19px] font-semibold leading-[1.3] tracking-[-0.01em]', on ? 'text-primary-foreground' : 'text-foreground')}>{it.q}</span>
                  <span id={`${uid}-a${i}`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                    <span className="min-h-0 overflow-hidden">
                      <span className="mt-3 block text-pretty text-[15.5px] leading-[1.55] text-primary-foreground/90">{it.a.join(' ')}</span>
                    </span>
                  </span>
                  <span className={cn('mt-auto flex items-center justify-between pt-5 text-[13px] font-medium', on ? 'text-primary-foreground/85' : 'text-muted-foreground')}>
                    {on ? guest?.name : t.showAnswer}
                    <Icon name="plus" size={16} className={cn('transition-transform duration-500 ease-[var(--ease-kit)]', on && 'rotate-45')} />
                  </span>
                </button>
              </li>
            );
          })}
        </Stagger>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* highlights                                                          */
/* ------------------------------------------------------------------ */

function Highlights(p: InterviewProps & { t: Texts }) {
  const { t } = p;
  const quotes = p.quotes ?? [];
  const guest = p.people?.[0];
  const [i, setI] = useState(0);
  const [playing, setPlaying] = useState(false);
  const DURATION = 6000;
  const ref = useRef<HTMLDivElement>(null);
  // Start playing once the quotes are in view, never with reduced motion
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const io = new IntersectionObserver(([e]) => setPlaying(e.isIntersecting), { threshold: 0.4 });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  useEffect(() => {
    if (!playing || quotes.length < 2) return;
    const id = window.setTimeout(() => setI((n) => (n + 1) % quotes.length), DURATION);
    return () => window.clearTimeout(id);
  }, [playing, i, quotes.length]);
  const go = (n: number) => setI((n + quotes.length) % quotes.length);
  const words = (quotes[i] ?? '').split(' ');
  return (
    <section ref={ref} className={cn('kit-glow relative overflow-x-clip py-16 sm:py-20 lg:py-28', p.className)}>
      <div className="kit-container relative">
        <div className="mx-auto max-w-[52rem] text-center">
          <Eyebrow className="kit-rise">{p.eyebrow ?? t.interview}</Eyebrow>
          {p.title && <h2 className="kit-rise text-balance text-[22px] font-semibold text-muted-foreground sm:text-[24px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
          <div className="relative mt-10 grid min-h-[13rem] place-items-center sm:min-h-[12rem]" aria-live="polite">
            <blockquote key={i} className="text-balance text-[28px] font-semibold leading-[1.22] tracking-[-0.022em] text-foreground sm:text-[40px] lg:text-[46px]">
              <span className="sr-only">{t.quote.replace('{a}', String(i + 1)).replace('{b}', String(quotes.length))}. </span>
              {words.map((w, k) => (
                <Fragment key={k}>
                  <span className="kit-rise inline-block" style={{ '--kit-delay': `${k * 35}ms`, animationDuration: '.6s' } as React.CSSProperties}>
                    {w}
                  </span>
                  {k < words.length - 1 ? ' ' : ''}
                </Fragment>
              ))}
            </blockquote>
          </div>
          {guest && (
            <div className="mt-8 inline-flex items-center gap-3">
              <Face p={guest} className="size-11" />
              <p className="text-left text-[14px] leading-snug">
                <span className="block font-semibold text-foreground">{guest.name}</span>
                {guest.role && <span className="text-muted-foreground">{guest.role}</span>}
              </p>
            </div>
          )}
          <div className="mx-auto mt-10 flex max-w-[30rem] items-center gap-3">
            <button type="button" onClick={() => go(i - 1)} aria-label={t.previous} className="grid size-11 shrink-0 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent">
              <Icon name="arrow-left" size={16} />
            </button>
            <div className="flex flex-1 gap-1.5">
              {quotes.map((_, k) => (
                <button key={k} type="button" onClick={() => go(k)} aria-label={t.quote.replace('{a}', String(k + 1)).replace('{b}', String(quotes.length))} aria-current={k === i ? 'true' : undefined} className="group/b relative flex h-11 flex-1 items-center">
                  <span className="block h-1 w-full overflow-hidden rounded-full bg-border">
                    <span
                      key={k === i ? `${i}-${playing}` : k}
                      className={cn('block h-full origin-left rounded-full bg-primary', k < i ? 'scale-x-100' : k > i ? 'scale-x-0' : playing ? 'motion-safe:animate-kit-progress' : 'scale-x-100')}
                      style={{ '--kit-duration': `${DURATION}ms` } as React.CSSProperties}
                    />
                  </span>
                </button>
              ))}
            </div>
            <button type="button" onClick={() => go(i + 1)} aria-label={t.next} className="grid size-11 shrink-0 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent">
              <Icon name="arrow-right" size={16} />
            </button>
            <button type="button" onClick={() => setPlaying((v) => !v)} aria-label={playing ? t.pause : t.play} aria-pressed={!playing} className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
              <Icon name={playing ? 'pause' : 'play'} size={15} />
            </button>
          </div>
          {p.action && (
            <a href={p.action.href} className="group/a mt-10 inline-flex min-h-11 items-center gap-2 text-[15px] font-medium text-foreground">
              {p.action.label}
              <Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover/a:translate-x-0.5" />
            </a>
          )}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function Interview(props: InterviewProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'classic' } = props;
  if (variant === 'portrait') return <Portrait {...props} t={t} />;
  if (variant === 'round-table') return <RoundTable {...props} t={t} />;
  if (variant === 'quick-fire') return <QuickFire {...props} t={t} />;
  if (variant === 'highlights') return <Highlights {...props} t={t} />;
  return <Classic {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Interview?

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

Welche Varianten hat Interview?

5 Varianten, Frage und Antwort, Porträt, Gesprächsrunde, Schnelle Fragen und Höhepunkte. Du wählst sie mit der Prop variant, zum Beispiel variant="portrait".

Kann ich die Texte von Interview übersetzen?

Ja. Interview bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Interview“, „Alle Stimmen“ und „Antwort zeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Interview einbauen?

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

Kann ich Interview ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert