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
Variants
Details
What Interview brings along, read from the registry and the source.
- Category
- Authors and interviews
- Area
- Content
- 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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The InterviewProps interface from components/ui/interview.tsx, with the comments from the code.
variantoptionalInterviewVariantclassicportraitround-tablequick-firehighlights
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodehostoptionalInterviewPersonWho asks
peopleoptionalInterviewPerson[]Who answers; round-table: everyone at the table
itemsoptionalInterviewItem[]mediaoptionalReact.ReactNodeportrait: the large picture
factsoptional{ label: string; value: string }[]portrait: facts, e.g. [{ label: 'Studio', value: 'Graz, since 2014' }]
quotesoptionalstring[]highlights: the sentences
actionoptional{ label: string; href: string }highlights: link to the whole conversation
dateoptionalstringAs 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
| Key | English | German |
|---|---|---|
| interview | English Interview | German Interview |
| by | English Questions by {name} | German Fragen von {name} |
| all | English All voices | German Alle Stimmen |
| showAnswer | English Show answer | German Antwort zeigen |
| reveal | English Reveal all answers | German Alle Antworten zeigen |
| hide | English Hide all answers | German Alle Antworten verbergen |
| quote | English Quote {a} of {b} | German Zitat {a} von {b} |
| previous | English Previous quote | German Vorheriges Zitat |
| next | English Next quote | German Nächstes Zitat |
| pause | English Pause | German Anhalten |
| play | English Play | German 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
'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.