AI input
Voice input
A mic button for dictating a prompt that stretches into a listening bar with a live waveform, a rolling timer and the words so far, then settles into Transcribing and shrinks back, or opens the reason when the mic is blocked.
Live preview
Loading preview
A prompt row with the mic next to Send (align end). Pressing the mic stretches it into the bar; a fake level drives the waveform, the timer rolls, and the words "Book a table for four on Friday" appear above; Stop turns the bar into Transcribing and then back to the mic. A second demo starts in blocked to show the alert and Try again. Replay runs the first demo again.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add voice-inputUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { VoiceInput, type VoiceInputStatus } from './voice-input';
const WORDS = 'Book a table for four on Friday'.split(' ');
function useDictation(initial: VoiceInputStatus, words: boolean) {
const [status, setStatus] = React.useState<VoiceInputStatus>(initial);
const [level, setLevel] = React.useState(0);
const [transcript, setTranscript] = React.useState('');
const [startedAt, setStartedAt] = React.useState<number>();
const [text, setText] = React.useState('');
const timers = React.useRef<number[]>([]);
const clear = React.useCallback(() => {
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
}, []);
const later = (ms: number, run: () => void) => timers.current.push(window.setTimeout(run, ms));
// A fake mic level and the words arriving one by one while listening.
React.useEffect(() => {
if (status !== 'listening') return;
const started = performance.now();
const id = window.setInterval(() => {
const t = (performance.now() - started) / 1000;
setLevel(0.35 + 0.3 * Math.sin(t * 9) * Math.sin(t * 2.3) + 0.2 * Math.sin(t * 17));
}, 90);
return () => window.clearInterval(id);
}, [status]);
const start = () => {
clear();
setTranscript('');
setText('');
setStartedAt(Date.now());
setStatus('listening');
if (!words) return;
for (let i = 1; i <= WORDS.length; i++)
later(220 + i * 280, () => setTranscript(WORDS.slice(0, i).join(' ')));
};
const stop = () => {
clear();
setStatus('transcribing');
later(1200, () => {
setText(WORDS.join(' '));
setStatus('idle');
});
};
const cancel = () => {
clear();
setTranscript('');
setStatus('idle');
};
React.useEffect(() => clear, [clear]);
return { status, level, transcript, startedAt, text, start, stop, cancel, setStatus, later, clear };
}
export default function VoiceInputDemo({ replay = 0 }: { replay?: number }) {
const main = useDictation('idle', true);
const blocked = useDictation('blocked', false);
const mainRef = React.useRef(main);
mainRef.current = main;
// Replay: press the mic, let the words arrive, then press Stop.
React.useEffect(() => {
if (replay === 0) return;
mainRef.current.start();
mainRef.current.later(3200, () => mainRef.current.stop());
}, [replay]);
return (
<div className="flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col gap-2 rounded-2xl border border-secondary bg-primary_alt p-3 shadow-xs">
<p
className={
main.text ? 'min-h-6 px-1 text-sm text-primary' : 'min-h-6 px-1 text-sm text-placeholder'
}
>
{main.text || 'Write a message'}
</p>
<div className="flex items-end gap-2">
<VoiceInput
status={main.status}
level={main.level}
transcript={main.transcript}
startedAt={main.startedAt}
align="end"
onStart={main.start}
onStop={main.stop}
onCancel={main.cancel}
/>
<button
type="button"
aria-label="Send"
className="mb-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-solid text-fg-white outline-none focus-visible:shadow-focus-ring"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
>
<path d="M8 12.667V3.333M3.333 8 8 3.333 12.667 8" />
</svg>
</button>
</div>
</div>
<VoiceInput
status={blocked.status}
level={blocked.level}
transcript={blocked.transcript}
startedAt={blocked.startedAt}
onStart={() => {
blocked.start();
blocked.later(1800, () => blocked.setStatus('error'));
}}
onStop={blocked.stop}
/>
</div>
);
}
API
Parts: root, transcript (folds open above the bar), surface (the mic button that stretches into the bar), mic button (mic, stop, spinner, mic off icons), waveform (bars), status label (Transcribing, over the waveform), timer (number roll, minutes and seconds), cancel button, alert panel (title, message, Try again button), live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "listening" | "transcribing" | "blocked" | "error" | - | Where the dictation is. Your recording code owns the mic and sets this. Required. |
| level | number | undefined | Current mic level from 0 to 1; the waveform scrolls with it. Without it the bars move in a soft wave. |
| transcript | string | undefined | The words recognized so far; shown above the bar while listening. |
| startedAt | number | undefined | When listening started (ms timestamp); shows the timer. |
| errorText | string | undefined | Message under the alert title; replaces the default message for blocked or error. |
| align | "start" | "end" | "start" | Side the mic sits on; the bar grows from there. Use end for a mic next to Send. |
| Event | Description |
|---|---|
| start | The mic was pressed in idle, or Try again was pressed in blocked or error. Setting this listener shows Try again. |
| stop | The person pressed Stop while listening; transcribe what was heard. |
| cancel | The person pressed Cancel while listening; drop the recording. Setting this listener shows the cancel button. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| VI1 | Listening starts, or transcribing ends | surface | width, height, padding | 36px, 36px, 1px → full width, 44px, 5px on the mic side and 8px on the other | duration-4xl (520)ease-out | Resizes at once |
| VI2 | Status changes | mic button icons | opacity, blur | 0, blur-swap → 1, 0 (the leaving icon does the reverse) | duration-md (260)ease-out | Swaps at once |
| VI3 | Status changes | mic button icons | scale | scale-swap → 1 (the leaving icon does the reverse) | duration-xl (380)ease-out | Swaps at once |
| VI4 | While transcribing | spinner | rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| VI5 | Status changes | mic button | background color, icon color | transparent, fg-tertiary → bg-brand-solid, fg-white (listening) or bg-tertiary, fg-tertiary (blocked, error) | duration-lg (320)ease-out | Swaps at once |
| VI6 | Stop pressed while listening | mic button | scale | 1 → scale-press | duration-lg (320)ease-out | Stays still |
| VI7 | Content shows: bar opens (waveform, timer, cancel), or transcribing starts (status label) | bar content | opacity, blur | 0, blur-text → 1, 0 | duration-lg (320)ease-outdelay 140ms | Shows at once |
| VI8 | Content hides: listening ends (waveform, timer, cancel), or the bar closes (status label) | bar content | opacity, blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| VI9 | Listening to transcribing | status label | text morph | Listening → Transcribing | duration-3xl (460)ease-out | Swaps at once |
| VI10 | While transcribing | status label | mask-position sheen | 150% → -50%, repeats | duration-loop (1600)ease-linear | No loop |
| VI11 | While listening with no level | waveform bars | scale y (loop) | 0.35 → 1, then 0.35 | duration-6xl (900)ease-loopdelay 90 x bar | No loop |
| VI12 | Level changes while listening | waveform bars (scroll one bar per level) | height | old level → new level, at least 12% | duration-2xs (120)ease-out | Runs as is |
| VI13 | Each second while listening | timer | number roll | 0:07 → 0:08 | duration-4xl (520)ease-out | Swaps at once |
| VI14 | First words arrive, or listening ends | transcript | height (grid rows) | 0fr → 1fr (back to 0fr when listening ends) | duration-xl (380)ease-out | Resizes at once |
| VI15 | First words arrive | transcript | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| VI16 | Listening ends | transcript | opacity | 1 → 0 | duration-xs (160)ease-out | Hides at once |
| VI17 | Blocked or error starts or ends | alert panel | height (grid rows) | 0fr → 1fr (back to 0fr when it ends) | duration-2xl (420)ease-out | Resizes at once |
| VI18 | Blocked or error starts | alert panel | opacity, blur | 0, blur-text → 1, 0 | duration-lg (320)ease-outdelay 120ms | Shows at once |
| VI19 | Blocked or error ends | alert panel | opacity, blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| VI20 | Blocked to error, or back | alert title | text morph | Microphone is blocked → No speech heard | duration-3xl (460)ease-out | Swaps at once |
| VI21 | Hover the idle mic, cancel or Try again | mic button, cancel button, Try again button | background color, text color | transparent, fg-tertiary → bg-tertiary, fg-primary | duration-xs (160)ease-out | Swaps at once |
Accessibility
- The mic button name follows the status: "Speak" (idle), "Stop listening" (listening), "Transcribing" (transcribing), "Microphone is blocked" (blocked) or "No speech heard" (error); it has aria-disabled when it has no action.
- A visually hidden role="status" line says "Listening" or "Transcribing", so each step is announced once.
- The transcript is aria-live="polite", so new words are read as they arrive.
- In blocked and error the row is role="alert": the title and the message are read, and Try again (a real button) follows.
- The waveform, the status label, the timer and all icons are aria-hidden; the cancel button is named "Cancel" and is inert while hidden.
- Buttons are 32px (mic) and 28px (cancel) with a visible focus ring (Focus rings/focus-ring).