Voice assistant
Voice interfaces in four forms. A conversation around an orb that listens, thinks and speaks with live captions, the transcript of a call the assistant took with playback and summary, choosing a voice with samples, and push to talk with voice messages.
One of 2 components in AI assistants
Variants
Details
What Voice assistant brings along, read from the registry and the source.
- Category
- AI assistants
- Area
- App
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/voice-assistant.tsx
- Brings along
- utilskiticonrevealmotion
- npm packages
- None
- Languages
- English and German, 40 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The VoiceAssistantProps interface from components/ui/voice-assistant.tsx, with the comments from the code.
variantoptionalVoiceAssistantVariantorbcall-transcriptvoice-pickerpush-to-talk
titleoptionalstringnameoptionalstringName of the assistant
linesoptionalVoiceLine[]orb: what the assistant hears and says, played in turns
calloptional{ caller: string; number?: string; duration: number; date: string; outcome?: string }call-transcript: caller and call facts
summaryoptional{ label: string; value: string }[]call-transcript: what the assistant took from the call
voicesoptionalVoiceOption[]voice-picker
defaultVoiceoptionalstringexchangeoptional{ heard: string; reply: string }push-to-talk: what the demo hears and answers when you let go. Connect real speech here.
noteoptionalstringShort line on what happens with audio
Built-in texts
Voice assistant writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 40 texts
| Key | English | German |
|---|---|---|
| listening | English Listening | German Hört zu |
| thinking | English Thinking | German Denkt nach |
| speaking | English Speaking | German Spricht |
| idle | English Tap to talk | German Tippen und sprechen |
| paused | English Microphone off | German Mikrofon aus |
| mute | English Turn microphone off | German Mikrofon ausschalten |
| unmute | English Turn microphone on | German Mikrofon einschalten |
| end | English End conversation | German Gespräch beenden |
| captions | English Captions | German Untertitel |
| you | English You | German Du |
| restart | English Start again | German Neu starten |
| ended | English Conversation ended. The transcript stays in your history. | German Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf. |
| play | English Play recording | German Aufnahme abspielen |
| pause | English Pause | German Pausieren |
| seek | English Position in the recording | German Stelle in der Aufnahme |
| caller | English Caller | German Anrufer |
| assistant | English Assistant | German Assistent |
| summary | English What was agreed | German Was vereinbart wurde |
| jump | English Play from {time} | German Ab {time} abspielen |
| voice | English Voice | German Stimme |
| quoteOpen | English “ | German „ |
| quoteClose | English ” | German “ |
| preview | English Play sample of {name} | German Hörprobe von {name} abspielen |
| stopPreview | English Stop sample | German Hörprobe stoppen |
| speed | English Speed | German Tempo |
| pitch | English Pitch | German Tonhöhe |
| slower | English slower | German langsamer |
| faster | English faster | German schneller |
| lower | English lower | German tiefer |
| higher | English higher | German höher |
| use | English Use this voice | German Diese Stimme nutzen |
| inUse | English In use | German In Nutzung |
| hold | English Hold to talk | German Halten und sprechen |
| release | English Release to send | German Loslassen zum Senden |
| recording | English Recording | German Nimmt auf |
| showText | English Show text | German Text zeigen |
| hideText | English Hide text | German Text verbergen |
| spaceHint | English Or hold the space bar | German Oder Leertaste halten |
| heard | English Is the delivery for Kantstraße 12 still on for Thursday? | German Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag? |
| reply | English Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before. | German Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an. |
Accessibility
Read from the source of the component.
- Voice assistant handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-live, aria-pressed and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 683 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
export type VoiceAssistantVariant = 'orb' | 'call-transcript' | 'voice-picker' | 'push-to-talk';
export interface VoiceLine {
id: string;
speaker: 'assistant' | 'caller' | 'user';
text: string;
/** Seconds from the start */
at?: number;
/** Seconds of audio, for voice messages */
duration?: number;
}
export interface VoiceOption {
id: string;
name: string;
/** e.g. "Warm, calm" */
character: string;
languages: string[];
/** A sentence the sample says */
sample: string;
tag?: string;
}
export interface VoiceAssistantProps {
variant?: VoiceAssistantVariant;
title?: string;
/** Name of the assistant */
name?: string;
/** orb: what the assistant hears and says, played in turns */
lines?: VoiceLine[];
/** call-transcript: caller and call facts */
call?: { caller: string; number?: string; duration: number; date: string; outcome?: string };
/** call-transcript: what the assistant took from the call */
summary?: { label: string; value: string }[];
/** voice-picker */
voices?: VoiceOption[];
defaultVoice?: string;
/** push-to-talk: what the demo hears and answers when you let go. Connect real speech here. */
exchange?: { heard: string; reply: string };
/** Short line on what happens with audio */
note?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
listening: 'Listening',
thinking: 'Thinking',
speaking: 'Speaking',
idle: 'Tap to talk',
paused: 'Microphone off',
mute: 'Turn microphone off',
unmute: 'Turn microphone on',
end: 'End conversation',
captions: 'Captions',
you: 'You',
restart: 'Start again',
ended: 'Conversation ended. The transcript stays in your history.',
play: 'Play recording',
pause: 'Pause',
seek: 'Position in the recording',
caller: 'Caller',
assistant: 'Assistant',
summary: 'What was agreed',
jump: 'Play from {time}',
voice: 'Voice',
quoteOpen: '“',
quoteClose: '”',
preview: 'Play sample of {name}',
stopPreview: 'Stop sample',
speed: 'Speed',
pitch: 'Pitch',
slower: 'slower',
faster: 'faster',
lower: 'lower',
higher: 'higher',
use: 'Use this voice',
inUse: 'In use',
hold: 'Hold to talk',
release: 'Release to send',
recording: 'Recording',
showText: 'Show text',
hideText: 'Hide text',
spaceHint: 'Or hold the space bar',
heard: 'Is the delivery for Kantstraße 12 still on for Thursday?',
reply: 'Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before.',
},
de: {
listening: 'Hört zu',
thinking: 'Denkt nach',
speaking: 'Spricht',
idle: 'Tippen und sprechen',
paused: 'Mikrofon aus',
mute: 'Mikrofon ausschalten',
unmute: 'Mikrofon einschalten',
end: 'Gespräch beenden',
captions: 'Untertitel',
you: 'Du',
restart: 'Neu starten',
ended: 'Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf.',
play: 'Aufnahme abspielen',
pause: 'Pausieren',
seek: 'Stelle in der Aufnahme',
caller: 'Anrufer',
assistant: 'Assistent',
summary: 'Was vereinbart wurde',
jump: 'Ab {time} abspielen',
voice: 'Stimme',
quoteOpen: '„',
quoteClose: '“',
preview: 'Hörprobe von {name} abspielen',
stopPreview: 'Hörprobe stoppen',
speed: 'Tempo',
pitch: 'Tonhöhe',
slower: 'langsamer',
faster: 'schneller',
lower: 'tiefer',
higher: 'höher',
use: 'Diese Stimme nutzen',
inUse: 'In Nutzung',
hold: 'Halten und sprechen',
release: 'Loslassen zum Senden',
recording: 'Nimmt auf',
showText: 'Text zeigen',
hideText: 'Text verbergen',
spaceHint: 'Oder Leertaste halten',
heard: 'Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag?',
reply: 'Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = VoiceAssistantProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const CSS =
'@keyframes vca-up{from{opacity:0;transform:translateY(8px)}}@keyframes vca-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}@keyframes vca-ring{from{transform:scale(.9);opacity:.6}to{transform:scale(1.5);opacity:0}}@keyframes vca-spin{to{transform:rotate(360deg)}}@keyframes vca-bar{0%,100%{transform:scaleY(.25)}50%{transform:scaleY(1)}}' +
'@media (prefers-reduced-motion:no-preference){.vca-up{animation:vca-up .45s var(--ease-kit) var(--vca-d,0ms) both}.vca-breathe{animation:vca-breathe 3.2s ease-in-out infinite}.vca-ring{animation:vca-ring 2s ease-out infinite}.vca-spin{animation:vca-spin 6s linear infinite}.vca-bar{animation:vca-bar var(--vca-t,.9s) ease-in-out var(--vca-d,0ms) infinite}}.vca-bar{transform-origin:50% 50%}';
function Style() {
return (
<style href="kit-voice-assistant" precedence="default">
{CSS}
</style>
);
}
/** Bar heights, fixed so server and browser agree */
const wave = (n: number, seed = 1) => Array.from({ length: n }, (_, i) => round(0.2 + 0.8 * Math.abs(Math.sin(i * 0.9 * seed + seed) * Math.cos(i * 0.31 + seed * 2)), 2));
function Stage({ title, children, className, wide, dark }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean; dark?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', dark && 'dark bg-background', className)}>
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[64rem]' : 'max-w-[44rem]')}>
{title && (
<Reveal className="mb-6 sm:mb-8">
<h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
const round_ = 'grid size-14 place-items-center rounded-full transition-[background-color,transform] duration-200 active:scale-95';
/* ------------------------------------------------------------------ */
/* orb */
/* ------------------------------------------------------------------ */
type Phase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended';
function Orb({ phase, level }: { phase: Phase; level: number }) {
const live = phase !== 'idle' && phase !== 'ended';
return (
<div aria-hidden className="relative mx-auto grid size-56 place-items-center sm:size-64">
{phase === 'listening' && (
<>
<span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_50%,transparent)]" />
<span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,transparent)] [animation-delay:1s]" />
</>
)}
<span
className={cn('absolute inset-8 rounded-full transition-[transform,opacity] duration-500 ease-[var(--ease-kit)]', live ? 'vca-breathe' : 'opacity-70')}
style={{
background: 'radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--primary) 30%, white) 0%, var(--primary) 38%, color-mix(in oklab, var(--primary) 55%, black) 100%)',
boxShadow: '0 30px 80px -20px color-mix(in oklab, var(--primary) 70%, transparent), inset 0 -12px 40px color-mix(in oklab, black 30%, transparent)',
transform: `scale(${round(0.92 + level * 0.12, 3)})`,
}}
/>
<span className={cn('absolute inset-14 rounded-full border border-[color-mix(in_oklab,var(--primary-foreground)_28%,transparent)]', phase === 'thinking' && 'vca-spin border-dashed')} />
{phase === 'speaking' && (
<span className="relative flex h-10 items-center gap-1">
{wave(9, 2).map((h, i) => (
<span key={i} className="vca-bar w-1.5 rounded-full bg-[color-mix(in_oklab,var(--primary-foreground)_90%,transparent)]" style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${i * 80}ms`, '--vca-t': `${700 + (i % 3) * 160}ms` } as React.CSSProperties} />
))}
</span>
)}
</div>
);
}
function OrbView({ t, title, name, lines = [], note, className }: P) {
const [phase, setPhase] = useState<Phase>('idle');
const [i, setI] = useState(0);
const [shown, setShown] = useState('');
const [muted, setMuted] = useState(false);
const [level, setLevel] = useState(0);
const [captions, setCaptions] = useState(true);
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
const started = useRef(false);
// Plays the lines in turns: the user is heard, the assistant thinks, then speaks
useEffect(() => {
if (phase === 'idle' || phase === 'ended' || muted) return;
const line = lines[i];
if (!line) return setPhase('ended');
if (prefersReducedMotion()) {
setShown(line.text);
const id = window.setTimeout(() => (setI((n) => n + 1), setPhase(lines[i + 1]?.speaker === 'assistant' ? 'speaking' : 'listening')), 1200);
return () => window.clearTimeout(id);
}
if (phase === 'thinking') {
const id = window.setTimeout(() => setPhase('speaking'), 900);
return () => window.clearTimeout(id);
}
const words = line.text.split(' ');
let n = 0;
let next = 0;
setShown('');
const id = window.setInterval(() => {
n++;
setShown(words.slice(0, n).join(' '));
setLevel(round(0.3 + 0.7 * Math.abs(Math.sin(n * 1.7)), 2));
if (n >= words.length) {
window.clearInterval(id);
next = window.setTimeout(() => {
setLevel(0);
setI((k) => k + 1);
const next = lines[i + 1];
setPhase(!next ? 'ended' : next.speaker === 'assistant' ? 'thinking' : 'listening');
}, 900);
}
}, 190);
// Muting or ending in the pause after a line must not start the next one
return () => (window.clearInterval(id), window.clearTimeout(next));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, i, muted]);
useEffect(() => {
if (!inView || started.current || prefersReducedMotion()) return;
// The lock is set when the timer fires: strict mode clears the first timer and
// starts a second one, and scrolling away and back never restarts the talk
const id = window.setTimeout(() => {
started.current = true;
setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening');
}, 800);
return () => window.clearTimeout(id);
}, [inView, lines]);
const start = () => (setI(0), setShown(''), setMuted(false), setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening'));
const line = lines[Math.min(i, lines.length - 1)];
const status = muted ? t.paused : phase === 'idle' ? t.idle : phase === 'ended' ? t.ended : t[phase];
const who = phase === 'listening' ? t.you : name;
return (
<Stage title={title} className={className} dark>
<div ref={ref} className="relative overflow-hidden rounded-[28px] border border-border bg-[radial-gradient(120%_80%_at_50%_0%,color-mix(in_oklab,var(--primary)_18%,var(--background)),var(--background)_60%)] px-5 pb-6 pt-8 text-center shadow-[var(--shadow-lg)] sm:px-10 sm:pt-10">
<p className="text-[13px] font-medium text-muted-foreground">{name}</p>
<p className="mt-1 text-[15px] font-medium text-foreground" aria-live="polite">
{status}
</p>
<button type="button" onClick={phase === 'idle' || phase === 'ended' ? start : () => setMuted((m) => !m)} aria-label={phase === 'idle' || phase === 'ended' ? t.idle : muted ? t.unmute : t.mute} className="mx-auto mt-2 block rounded-full focus-visible:outline-offset-[-12px]">
<Orb phase={muted ? 'idle' : phase} level={level} />
</button>
<div className="mx-auto mt-2 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
{captions && phase !== 'idle' && phase !== 'ended' && line && (
<>
<p className="text-[12px] font-medium text-subtle-foreground">{who}</p>
<p className={cn('mt-1 text-balance text-[17px] leading-relaxed sm:text-[19px]', phase === 'listening' ? 'text-muted-foreground' : 'text-foreground')}>
{phase === 'thinking' ? '' : shown}
{phase !== 'thinking' && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />}
</p>
</>
)}
{phase === 'ended' && <p className="vca-up text-[14px] text-muted-foreground">{t.ended}</p>}
</div>
<div className="mt-4 flex items-center justify-center gap-4">
<button type="button" aria-label={t.captions} aria-pressed={captions} onClick={() => setCaptions((c) => !c)} className={cn(round_, 'size-12 border', captions ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : 'border-border bg-card text-muted-foreground hover:bg-accent')}>
<Icon name="captions" size={20} />
</button>
{phase === 'ended' || phase === 'idle' ? (
<button type="button" onClick={start} aria-label={t.restart} className={cn(round_, 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}>
<Icon name="mic" size={24} />
</button>
) : (
<button type="button" aria-pressed={muted} aria-label={muted ? t.unmute : t.mute} onClick={() => setMuted((m) => !m)} className={cn(round_, muted ? 'bg-foreground text-background' : 'border border-border bg-card text-foreground hover:bg-accent')}>
<Icon name={muted ? 'volume-x' : 'mic'} size={24} />
</button>
)}
<button type="button" aria-label={t.end} onClick={() => setPhase('ended')} disabled={phase === 'ended' || phase === 'idle'} className={cn(round_, 'size-12 bg-destructive text-destructive-foreground disabled:opacity-40')}>
<Icon name="x" size={20} />
</button>
</div>
{note && (
<p className="mx-auto mt-6 flex max-w-[28rem] items-start justify-center gap-1.5 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="lock" size={13} className="mt-px shrink-0" />
{note}
</p>
)}
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* call-transcript */
/* ------------------------------------------------------------------ */
const WAVE = wave(96, 1.3);
function CallTranscript({ t, title, name, lines = [], call, summary = [], className }: P) {
const total = call?.duration ?? 60;
const [pos, setPos] = useState(0);
const [playing, setPlaying] = useState(false);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => setPos((p) => (p >= total ? (setPlaying(false), total) : p + 1)), prefersReducedMotion() ? 1000 : 250);
return () => window.clearInterval(id);
}, [playing, total]);
const current = [...lines].reverse().find((l) => (l.at ?? 0) <= pos)?.id;
useEffect(() => {
if (!playing || !current) return;
const el = list.current?.querySelector<HTMLElement>(`[data-line="${current}"]`);
if (el && list.current) list.current.scrollTo({ top: el.offsetTop - list.current.offsetTop - 12, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [current, playing]);
const share = pos / total;
return (
<Stage title={title} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
<Reveal delay={60}>
<div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="flex items-center gap-3 border-b border-border p-4 sm:px-5">
<button type="button" aria-label={playing ? t.pause : t.play} onClick={() => (pos >= total && setPos(0), setPlaying((p) => !p))} className="grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<div className="min-w-0 flex-1">
<div className="relative flex h-10 items-center gap-[2px]" aria-hidden>
{WAVE.map((h, i) => (
<span key={i} className={cn('w-full rounded-full transition-colors duration-200', i / WAVE.length <= share ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]', i % 2 && 'max-sm:hidden')} style={{ height: `${Math.round(h * 100)}%` }} />
))}
</div>
<input type="range" min={0} max={total} value={pos} onChange={(e) => setPos(Number(e.target.value))} aria-label={t.seek} aria-valuetext={`${clock(pos)} / ${clock(total)}`} className="mt-1 h-5 w-full cursor-pointer accent-[var(--primary)]" />
</div>
<span className="w-20 shrink-0 text-right text-[12.5px] tabular-nums text-muted-foreground">
{clock(pos)} / {clock(total)}
</span>
</div>
<ol ref={list} className="kit-scrollbar relative max-h-[26rem] space-y-1 overflow-y-auto p-2 pb-6 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)] sm:p-3 sm:pb-6">
{lines.map((l) => {
const on = l.id === current && pos > 0;
const assistant = l.speaker === 'assistant';
return (
<li key={l.id} data-line={l.id}>
<button type="button" aria-label={fill(t.jump, { time: clock(l.at ?? 0) })} onClick={() => setPos(l.at ?? 0)} className={cn('grid w-full grid-cols-[3rem_minmax(0,1fr)] gap-3 rounded-xl px-2 py-2.5 text-left transition-colors duration-300 sm:px-3', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'hover:bg-accent/60')}>
<span className="pt-0.5 text-[12px] tabular-nums text-subtle-foreground">{clock(l.at ?? 0)}</span>
<span className="min-w-0">
<span className={cn('block text-[12px] font-medium', assistant ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-muted-foreground')}>{assistant ? name ?? t.assistant : call?.caller ?? t.caller}</span>
<span className={cn('mt-0.5 block text-pretty text-[14.5px] leading-relaxed', on ? 'text-foreground' : 'text-muted-foreground')}>{l.text}</span>
</span>
</button>
</li>
);
})}
</ol>
</div>
</Reveal>
<Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
{call && (
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center gap-3">
<span aria-hidden className="grid size-10 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground">
<Icon name="phone-call" size={18} />
</span>
<div className="min-w-0">
<p className="truncate text-[14.5px] font-medium text-foreground">{call.caller}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{[call.number, call.date].filter(Boolean).join(' · ')}
</p>
</div>
</div>
{call.outcome && (
<p className="mt-3 flex items-center gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-3 py-2 text-[13px] font-medium text-success-foreground">
<Icon name="check-circle" size={15} />
{call.outcome}
</p>
)}
</div>
)}
{summary.length > 0 && (
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="sparkle" size={14} className="text-primary" />
{t.summary}
</p>
<Stagger as="ul" step={70} className="divide-y divide-border">
{summary.map((s) => (
<li key={s.label} className="flex justify-between gap-3 py-2 text-[13px]">
<span className="text-muted-foreground">{s.label}</span>
<span className="text-right text-foreground">{s.value}</span>
</li>
))}
</Stagger>
</div>
)}
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* voice-picker */
/* ------------------------------------------------------------------ */
function VoicePicker({ t, title, voices = [], defaultVoice, note, className }: P) {
const locale = useLocale();
const [voice, setVoice] = useState(defaultVoice ?? voices[0]?.id);
const [used, setUsed] = useState(defaultVoice ?? voices[0]?.id);
const [playing, setPlaying] = useState<string | null>(null);
const [speed, setSpeed] = useState(100);
const [pitch, setPitch] = useState(0);
const refs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (!playing) return;
const id = window.setTimeout(() => setPlaying(null), 3200);
return () => window.clearTimeout(id);
}, [playing]);
const cur = voices.find((v) => v.id === voice);
const nf = (n: number) => (n / 100).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
const slider = (label: string, value: number, set: (n: number) => void, min: number, max: number, lo: string, hi: string, show: string) => (
<label className="block">
<span className="flex items-baseline justify-between text-[13px]">
<span className="font-medium text-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">{show}</span>
</span>
<input type="range" min={min} max={max} value={value} onChange={(e) => set(Number(e.target.value))} className="mt-2 h-6 w-full cursor-pointer accent-[var(--primary)]" />
<span className="flex justify-between text-[11.5px] text-subtle-foreground">
<span>{lo}</span>
<span>{hi}</span>
</span>
</label>
);
return (
<Stage title={title} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-6">
<Stagger role="radiogroup" aria-label={t.voice} step={80} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{voices.map((v, i) => {
const on = v.id === voice;
const live = playing === v.id;
return (
<div
key={v.id}
ref={(el) => {
refs.current[i] = el;
}}
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setVoice(v.id)}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), setVoice(v.id));
if (!d) return;
e.preventDefault();
const n = (i + d + voices.length) % voices.length;
setVoice(voices[n].id);
refs.current[n]?.focus();
}}
className={cn('cursor-pointer rounded-2xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border hover:border-border-strong')}
>
<div className="flex items-start gap-3">
<button
type="button"
aria-label={live ? t.stopPreview : fill(t.preview, { name: v.name })}
onClick={(e) => (e.stopPropagation(), setPlaying(live ? null : v.id))}
className={cn('grid size-11 shrink-0 place-items-center rounded-full transition-colors', live ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_20%,var(--card))]')}
>
<Icon name={live ? 'pause' : 'play'} size={17} />
</button>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
{v.name}
{v.tag && <span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-1.5 py-px text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_72%,var(--foreground))]">{v.tag}</span>}
</p>
<p className="text-[12.5px] text-muted-foreground">{v.character}</p>
</div>
<span className={cn('grid size-6 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-transparent')}>
<Icon name="check" size={13} strokeWidth={2.4} />
</span>
</div>
<div className="mt-3 flex h-8 items-center gap-[3px]" aria-hidden>
{wave(40, i + 1.4).map((h, k) => (
<span key={k} className={cn('w-full rounded-full', live ? 'vca-bar bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_16%,transparent)]')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 8) * 70}ms` } as React.CSSProperties} />
))}
</div>
<p className={cn('mt-2 text-pretty text-[13px] italic leading-relaxed transition-colors', live ? 'text-foreground' : 'text-muted-foreground')}>{t.quoteOpen}{v.sample}{t.quoteClose}</p>
<p className="mt-2 text-[12px] text-subtle-foreground">{v.languages.join(' · ')}</p>
</div>
);
})}
</Stagger>
<Reveal delay={160} className="lg:sticky lg:top-6">
<div className="space-y-5 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
<p className="text-[13px] font-medium text-foreground">{cur?.name}</p>
{slider(t.speed, speed, setSpeed, 75, 150, t.slower, t.faster, `${nf(speed)}×`)}
{slider(t.pitch, pitch, setPitch, -4, 4, t.lower, t.higher, pitch > 0 ? `+${pitch}` : String(pitch))}
<button type="button" onClick={() => setUsed(voice)} disabled={used === voice} aria-live="polite" className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-primary text-[13.5px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] disabled:text-success-foreground sm:h-9">
<Icon name={used === voice ? 'check-circle' : 'check'} size={15} />
{used === voice ? t.inUse : t.use}
</button>
</div>
{note && <p className="mt-3 px-1 text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* push-to-talk */
/* ------------------------------------------------------------------ */
function PushToTalk({ t, title, name, lines: initial = [], exchange, note, className }: P) {
const [lines, setLines] = useState(initial);
const [rec, setRec] = useState(false);
const [secs, setSecs] = useState(0);
const [open, setOpen] = useState<string[]>(initial.slice(-1).map((l) => l.id));
const [playing, setPlaying] = useState<string | null>(null);
const seq = useRef(0);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (!rec) return;
setSecs(0);
const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
return () => window.clearInterval(id);
}, [rec]);
useEffect(() => {
if (!playing) return;
const id = window.setTimeout(() => setPlaying(null), 2400);
return () => window.clearTimeout(id);
}, [playing]);
useEffect(() => {
const el = list.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [lines.length]);
const begin = () => setRec(true);
const finish = () => {
if (!rec) return;
setRec(false);
const a = `u${++seq.current}`;
const b = `a${seq.current}`;
setLines((s) => [...s, { id: a, speaker: 'user', text: exchange?.heard ?? t.heard, duration: Math.max(2, secs) }]);
setOpen((o) => [...o, a]);
window.setTimeout(() => (setLines((s) => [...s, { id: b, speaker: 'assistant', text: exchange?.reply ?? t.reply, duration: 4 }]), setOpen((o) => [...o, b])), prefersReducedMotion() ? 0 : 1100);
};
return (
<Stage title={title} className={className}>
<Reveal effect="scale" delay={60}>
<div className="mx-auto max-w-[26rem] overflow-hidden rounded-[28px] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<span aria-hidden className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground">
<Icon name="headset" size={17} />
</span>
<p className="text-[14.5px] font-semibold text-foreground">{name}</p>
</div>
<ol ref={list} className="kit-scrollbar relative h-[22rem] space-y-3 overflow-y-auto px-3 py-4">
{lines.map((l) => {
const mine = l.speaker === 'user';
const shown = open.includes(l.id);
const live = playing === l.id;
return (
<li key={l.id} className={cn('vca-up flex', mine ? 'justify-end' : 'justify-start')}>
<div className={cn('w-[82%] rounded-2xl px-3 py-2.5', mine ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] text-foreground')}>
<div className="flex items-center gap-2">
<button type="button" aria-label={live ? t.pause : t.play} onClick={() => setPlaying(live ? null : l.id)} className={cn('grid size-11 shrink-0 place-items-center rounded-full sm:size-9', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'bg-card text-foreground shadow-[var(--shadow-sm)]')}>
<Icon name={live ? 'pause' : 'play'} size={15} />
</button>
<span className="flex h-7 min-w-0 flex-1 items-center gap-[2px]" aria-hidden>
{wave(28, l.text.length % 7 + 1).map((h, k) => (
<span key={k} className={cn('w-full rounded-full', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_80%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_40%,transparent)]', live && 'vca-bar')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 6) * 80}ms` } as React.CSSProperties} />
))}
</span>
<span className="text-[11.5px] tabular-nums opacity-80">{clock(l.duration ?? 3)}</span>
</div>
{shown && <p className="mt-2 text-pretty text-[13.5px] leading-snug">{l.text}</p>}
<button type="button" onClick={() => setOpen((o) => (shown ? o.filter((x) => x !== l.id) : [...o, l.id]))} className={cn('-mb-1.5 mt-0.5 inline-flex min-h-11 items-center text-[11.5px] font-medium underline-offset-2 hover:underline sm:mb-0 sm:mt-1 sm:min-h-8', mine ? 'text-[color-mix(in_oklab,var(--primary-foreground)_85%,transparent)]' : 'text-muted-foreground')}>
{shown ? t.hideText : t.showText}
</button>
</div>
</li>
);
})}
</ol>
<div className="border-t border-border px-4 pb-5 pt-4 text-center">
<p className={cn('mb-3 text-[13px] font-medium tabular-nums', rec ? 'text-destructive' : 'text-muted-foreground')} aria-live="polite">
{rec ? `${t.recording} ${clock(secs)}` : t.hold}
</p>
<button
type="button"
aria-pressed={rec}
aria-label={rec ? t.release : t.hold}
onPointerDown={(e) => (e.preventDefault(), begin())}
onPointerUp={finish}
onPointerLeave={finish}
onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && !e.repeat && (e.preventDefault(), begin())}
onKeyUp={(e) => (e.key === ' ' || e.key === 'Enter') && finish()}
className={cn('relative mx-auto grid size-20 touch-none select-none place-items-center rounded-full text-primary-foreground shadow-[var(--shadow-lg)] transition-[transform,background-color] duration-200', rec ? 'scale-110 bg-destructive' : 'bg-primary hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}
>
{rec && <span aria-hidden className="vca-ring absolute inset-0 rounded-full border-2 border-destructive" />}
<Icon name="mic" size={30} />
</button>
<p className="mt-3 text-[12px] text-subtle-foreground max-sm:hidden">{t.spaceHint}</p>
</div>
</div>
{note && <p className="mx-auto mt-3 max-w-[26rem] px-1 text-center text-[12.5px] text-muted-foreground">{note}</p>}
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* Voice interfaces: a full conversation around an orb that listens, thinks
* and speaks with live captions, the transcript of a call the assistant took
* with playback and summary, choosing a voice with samples, and push to talk
* with voice messages and their text.
*/
export function VoiceAssistant({ variant = 'orb', labels, ...props }: VoiceAssistantProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const key = JSON.stringify(props.lines ?? props.voices ?? null);
const body = (() => {
switch (variant) {
case 'call-transcript':
return <CallTranscript {...p} />;
case 'voice-picker':
return <VoicePicker key={key} {...p} />;
case 'push-to-talk':
return <PushToTalk key={key} {...p} />;
default:
return <OrbView key={key} {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Frequent questions
How do I install Voice assistant?
With aiw add @ai-website/voice-assistant. The command puts components/ui/voice-assistant.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.
Which variants does Voice assistant have?
4 variants, Voice conversation, Call transcript, Choose a voice and Push to talk. You pick one with the variant prop, for example variant="call-transcript".
Can I translate the texts of Voice assistant?
Yes. Voice assistant carries 40 texts of its own in English and German, such as “Listening”, “Thinking” and “Speaking”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Voice assistant?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Voice assistant with aiw_components and install it with aiw_add @ai-website/voice-assistant. Name the variant in your prompt, for example variant="orb", and the assistant writes your content straight into the props.
Can I adjust Voice assistant without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching VoiceAssistant code to copy.