Elements

Dictation

The mic morphs the input into a live waveform, then lands the transcript as text.

fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-composer"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Tapping the mic swaps the text input for a live waveform while a person talks, then settles into a brief "Transcribing" state before the words land as text. With a runtime this is backed by a DictationAdapter, most commonly the browser's own speech recognition; standalone you drive the recording flag and the elapsed timer yourself.

Getting started

Dictation is opt-in, the same way attachments are: without a DictationAdapter configured, s.thread.capabilities.dictation is false and ComposerPrimitive.Dictate renders disabled.

Configure a dictation adapter

assistant-ui ships a WebSpeechDictationAdapter over the browser's SpeechRecognition API; check .isSupported() before relying on it, since it is not available in every browser:

app/chat-provider.tsx
import { useLocalRuntime, WebSpeechDictationAdapter } from "@assistant-ui/react";

const runtime = useLocalRuntime(chatModel, {
  adapters: {
    dictation: WebSpeechDictationAdapter.isSupported()
      ? new WebSpeechDictationAdapter({ interimResults: true })
      : undefined,
  },
});

Start, show, and stop

components/assistant-ui/elements/composer-voice.tsx
"use client";

import { AuiIf, ComposerPrimitive } from "@assistant-ui/react";
import { MicIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, inkButton } from "@/components/assistant-ui/elements/surfaces";

export function DictationControl() {
  return (
    <AuiIf condition={(s) => s.thread.capabilities.dictation}>
      <AuiIf condition={(s) => s.composer.dictation == null}>
        <ComposerPrimitive.Dictate aria-label="Start voice input" className={cn(ghostButton, "size-8")}>
          <MicIcon className="size-4" />
        </ComposerPrimitive.Dictate>
      </AuiIf>
      <AuiIf condition={(s) => s.composer.dictation != null}>
        <ComposerPrimitive.StopDictation
          aria-label="Stop voice input"
          className={cn(inkButton, "flex size-8 items-center justify-center rounded-full")}
        >
          <SquareIcon className="size-3 fill-current" />
        </ComposerPrimitive.StopDictation>
      </AuiIf>
    </AuiIf>
  );
}

StopDictation only renders enabled while s.composer.dictation is non-null (like Send and Cancel, it never unmounts on its own); the AuiIf pair above mounts only the relevant button, the same swap Thread's own composer makes. Dictate starts a session through the configured adapter and writes recognized speech straight into s.composer.text as it arrives.

Anatomy

<div data-slot="composer-voice" data-recording={/* true while capturing */}>
  {/* a pulsing dot, only while recording */}
  <div>{/* 14 bars, animated only while recording */}</div>
  {/* a mono "0:SS" timer while recording, or a "Transcribing" shimmer once stopped */}
</div>

The two states are not "recording" and "idle": they are "recording" and "settling". ComposerVoice has no third, blank state, so mounting it always implies a capture is either happening or just finished; the bars themselves ripple from a sine wave seeded by the bar's index and the elapsed seconds, so the pattern shifts continuously rather than looping.

Examples

Checking support before offering the mic

const dictationAvailable = WebSpeechDictationAdapter.isSupported();

Configuring the adapter only when this is true (as in Getting started) is enough; s.thread.capabilities.dictation then reflects it automatically, so the mic button never needs its own support check.

Overlaying the live transcript

ComposerVoice shows a timer, not the words being spoken; layer ComposerPrimitive.DictationTranscript on top (or beside it) to show the interim transcript as it streams in, before it commits to s.composer.text:

<ComposerPrimitive.DictationTranscript className="text-foreground/55 absolute inset-x-3 bottom-full text-sm" />

Restyle the waveform

Both lanes render the same shape; the pulsing dot, the bars, the timer, and the shimmer label each take their own classes, so recoloring the recording state does not require touching the settling state.

<ComposerVoice recording={recording} seconds={seconds} className="gap-4" />

API reference

ComposerPrimitive

PartRendersNotes
DictatebuttonStarts a session via the configured DictationAdapter; disabled without one.
StopDictationbuttonOnly renders enabled while s.composer.dictation is non-null.
DictationTranscriptspanRenders the live interim transcript; renders nothing while there is none.

WebSpeechDictationAdapter

OptionTypeDefaultDescription
languagestringnavigator.languageBCP 47 language tag passed to SpeechRecognition.
continuousbooleantrueKeeps listening across pauses instead of stopping after one utterance.
interimResultsbooleantrueEmits partial results as speech is recognized, not only final ones.

WebSpeechDictationAdapter.isSupported() is a static method; the adapter itself throws if constructed and used where SpeechRecognition is unavailable.

Composer state

SelectorTypeDescription
s.composer.dictation{ status; transcript?: string; inputDisabled?: boolean } | undefinedNon-null exactly while a dictation session is active or finishing.
s.thread.capabilities.dictationbooleanWhether a DictationAdapter is configured.
aui.composer.startDictation()() => voidStarts a session; throws if no adapter is configured.
aui.composer.stopDictation()() => voidStops the current session and commits the final transcript.

status is { type: "starting" \| "running" } while listening, or { type: "ended", reason: "stopped" \| "cancelled" \| "error" } once it finishes; there is no elapsed-seconds field, so a live counter like ComposerVoice's is timed separately from status (a setInterval gated on s.composer.dictation != null, mirroring the standalone example).