Lunia DS

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.

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-input

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
status"idle" | "listening" | "transcribing" | "blocked" | "error"-Where the dictation is. Your recording code owns the mic and sets this. Required.
levelnumberundefinedCurrent mic level from 0 to 1; the waveform scrolls with it. Without it the bars move in a soft wave.
transcriptstringundefinedThe words recognized so far; shown above the bar while listening.
startedAtnumberundefinedWhen listening started (ms timestamp); shows the timer.
errorTextstringundefinedMessage 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.
EventDescription
startThe mic was pressed in idle, or Try again was pressed in blocked or error. Setting this listener shows Try again.
stopThe person pressed Stop while listening; transcribe what was heard.
cancelThe 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
VI1Listening starts, or transcribing endssurfacewidth, height, padding36px, 36px, 1px → full width, 44px, 5px on the mic side and 8px on the otherduration-4xl (520)ease-outResizes at once
VI2Status changesmic button iconsopacity, blur0, blur-swap → 1, 0 (the leaving icon does the reverse)duration-md (260)ease-outSwaps at once
VI3Status changesmic button iconsscalescale-swap → 1 (the leaving icon does the reverse)duration-xl (380)ease-outSwaps at once
VI4While transcribingspinnerrotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
VI5Status changesmic buttonbackground color, icon colortransparent, fg-tertiary → bg-brand-solid, fg-white (listening) or bg-tertiary, fg-tertiary (blocked, error)duration-lg (320)ease-outSwaps at once
VI6Stop pressed while listeningmic buttonscale1 → scale-pressduration-lg (320)ease-outStays still
VI7Content shows: bar opens (waveform, timer, cancel), or transcribing starts (status label)bar contentopacity, blur0, blur-text → 1, 0duration-lg (320)ease-outdelay 140msShows at once
VI8Content hides: listening ends (waveform, timer, cancel), or the bar closes (status label)bar contentopacity, blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
VI9Listening to transcribingstatus labeltext morphListening → Transcribingduration-3xl (460)ease-outSwaps at once
VI10While transcribingstatus labelmask-position sheen150% → -50%, repeatsduration-loop (1600)ease-linearNo loop
VI11While listening with no levelwaveform barsscale y (loop)0.35 → 1, then 0.35duration-6xl (900)ease-loopdelay 90 x barNo loop
VI12Level changes while listeningwaveform bars (scroll one bar per level)heightold level → new level, at least 12%duration-2xs (120)ease-outRuns as is
VI13Each second while listeningtimernumber roll0:07 → 0:08duration-4xl (520)ease-outSwaps at once
VI14First words arrive, or listening endstranscriptheight (grid rows)0fr → 1fr (back to 0fr when listening ends)duration-xl (380)ease-outResizes at once
VI15First words arrivetranscriptopacity0 → 1duration-lg (320)ease-outShows at once
VI16Listening endstranscriptopacity1 → 0duration-xs (160)ease-outHides at once
VI17Blocked or error starts or endsalert panelheight (grid rows)0fr → 1fr (back to 0fr when it ends)duration-2xl (420)ease-outResizes at once
VI18Blocked or error startsalert panelopacity, blur0, blur-text → 1, 0duration-lg (320)ease-outdelay 120msShows at once
VI19Blocked or error endsalert panelopacity, blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
VI20Blocked to error, or backalert titletext morphMicrophone is blocked → No speech heardduration-3xl (460)ease-outSwaps at once
VI21Hover the idle mic, cancel or Try againmic button, cancel button, Try again buttonbackground color, text colortransparent, fg-tertiary → bg-tertiary, fg-primaryduration-xs (160)ease-outSwaps 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).