Skip to content
UI Kit
Accent color

Interview

Interviews set so you hear who speaks. Question and answer with initials in the margin, a portrait beside the conversation, a round table where one voice can stand alone, quick questions as cards and the strongest quotes one by one.

One of 2 components in Authors and interviews

5 variants

Variants

Question and answer

One column, the initials of who speaks in the margin, the question in bold. A sentence of the answer returns as a pull quote.

Open in Studio

Portrait

A large portrait that drifts while scrolling and the facts about the person stay beside the conversation, the questions set as headings.

Open in Studio

Round table

Several voices, each with face and name. Pick one voice and the others step back, on phones the voices sit in a row to swipe.

Open in Studio

Quick questions

Short questions as cards. A press turns the card into the answer, one button reveals them all.

Open in Studio

Highlights

The strongest sentences one after another, word by word. A progress line runs while the section is in view, arrows and pause stay in your hands.

Open in Studio

Details

What Interview brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/interview.tsx
Brings along
utilskiticonrevealmotionsection
npm packages
None
Languages
English and German, 11 texts of its own

Props

The InterviewProps interface from components/ui/interview.tsx, with the comments from the code.

  • 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

Built-in texts

Interview writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 11 texts
interviewEnglish InterviewGerman Interview
byEnglish Questions by {name}German Fragen von {name}
allEnglish All voicesGerman Alle Stimmen
showAnswerEnglish Show answerGerman Antwort zeigen
revealEnglish Reveal all answersGerman Alle Antworten zeigen
hideEnglish Hide all answersGerman Alle Antworten verbergen
quoteEnglish Quote {a} of {b}German Zitat {a} von {b}
previousEnglish Previous quoteGerman Vorheriges Zitat
nextEnglish Next quoteGerman Nächstes Zitat
pauseEnglish PauseGerman Anhalten
playEnglish PlayGerman Abspielen

Accessibility

Read from the source of the component.

  • Sets aria-controls, aria-current, aria-expanded, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the role group.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 532 lines
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} />;
}

Frequent questions

How do I install Interview?

With aiw add @ai-website/interview. The command puts components/ui/interview.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and section. It needs no further npm packages.

Which variants does Interview have?

5 variants, Question and answer, Portrait, Round table, Quick questions and Highlights. You pick one with the variant prop, for example variant="portrait".

Can I translate the texts of Interview?

Yes. Interview carries 11 texts of its own in English and German, such as “Interview”, “All voices” and “Show answer”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Interview?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Interview with aiw_components and install it with aiw_add @ai-website/interview. Name the variant in your prompt, for example variant="classic", and the assistant writes your content straight into the props.

Can I adjust Interview without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Interview code to copy.

Often searched

Jump to

Copied to the clipboard