Dictation

Pro

A microphone button for the composer's tools slot: press it, speak, press it again. Words land in the box as they're recognised and stay editable; nothing sends until the person sends it. Its state is shown beside it in words (starting, listening, transcribing) and announced to screen readers, and a problem (a blocked microphone, nothing heard) says what happened.

Opt-in, and private by default: without transcribe it uses the browser's speech recognition only where that runs on the device, unless you set allowBrowserService. Where dictation can't run, the button is hidden, or says why with whenUnsupported="explain".

<Composer {...composer} tools={<DictationButton value={composer.value} onChange={composer.onChange} />} />

Markdown

Preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/dictation

Brings in: locale, locked, tooltip, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { Composer } from "@/components/composer"
import { DictationButton } from "@/components/dictation"

export default function Example() {
  const [value, setValue] = React.useState("")
  return (
    <Composer
      className="max-w-2xl"
      value={value}
      onChange={setValue}
      onSubmit={() => setValue("")}
      tools={
        // On-device speech recognition where the browser has it; where it doesn't, the button says why. To use your own
        // speech-to-text instead, pass transcribe={async (audio, { signal }) =>
        //   (await fetch("/api/transcribe", { method: "POST", body: audio, signal })).text()}
        <DictationButton value={value} onChange={setValue} whenUnsupported="explain" />
      }
    />
  )
}

<DictationButton>

PropTypeDefaultDescription
valuerequiredstring—The text the words go into, usually the composer's `value`.
onChangerequired(value: string) => void—Called as words arrive, usually the composer's `onChange`.
langstring | undefined—A BCP 47 language such as "en-GB". Left out, LocaleProvider's locale, else the page's `lang`, else the browser's.
transcribeTranscribe | undefined—Your own speech-to-text. With it, the browser's speech recognition isn't used; the recording goes only to this.
allowBrowserServiceboolean | undefined—Let the browser's own speech service hear the audio. Some browsers (Chrome among them) send it to the browser maker's servers. Off by default: without `transcribe`, dictation runs only where the browser recognises speech on the device, and is unavailable elsewhere.
maxSecondsnumber | undefined—Stops listening after this many seconds of listening (not counting the permission prompt); 120 by default.
whenUnsupported"hide" | "explain" | undefined"hide"Where dictation can't run: "hide" the button (the default), or "explain" with a locked button that says why.
disabledboolean | undefinedfalseOff while the composer can't take input, e.g. when it's locked or a reply is streaming.
classNamestring | undefined—Extra classes for the outer element.

Functions

useDictation({ value, onChange, lang: langProp, transcribe, allowBrowserService = false, maxSeconds = 120 }: DictationOptions)

Speech to text into a field you own. Words appear in the field as they're recognised (interim words are replaced as the recogniser settles on them) and stay plain, editable text: nothing is sent. Typing in the field while it listens keeps your edit and carries on after it. Emptying the field (sending the message) stops it and drops whatever it hadn't written yet, so nothing lands in the next message.

Audio goes only where you choose: to your transcribe, or to on-device recognition, or, with allowBrowserService, to whatever service the browser uses.

Strings

The fixed words, in English by default. Change any of them under dictation in <LocaleProvider strings={{ … }}>; the table is exported as dictationStrings.

KeyEnglish
dictateDictate
stopStop dictating
startingStarting the microphone…
listeningListening. Stop when you’re done, then check the text before you send.
transcribingTurning your recording into text…
stoppedStopped dictating. You can edit the text before you send it.
unsupportedDictation isn’t available in this browser.
notOnDeviceThis browser can’t turn speech into text on this device, so dictation is off.
deniedThe microphone is blocked. Allow it in the browser’s site settings to dictate.
noMicrophoneNo microphone was found.
noSpeechNothing was heard. Try again, closer to the microphone.
networkSpeech recognition couldn’t reach its service. Try again.
languageDictation doesn’t support this language here.
failedDictation stopped because of an error. Try again.
transcribeFailedYour recording couldn’t be turned into text. Try again.

Types

Transcribe
Your own speech-to-text: given the recording, return its text. Dictation then records with the browser's MediaRecorder and hands you the audio; where it goes from there is your choice.
(audio: Blob, options: { lang?: string | undefined; signal: AbortSignal }) => Promise<string>
DictationStatus
| "checking" | "unsupported" | "idle" /** Asking for the microphone, or readying on-device recognition. */ | "starting" | "listening" /** `transcribe` is working on the recording. */ | "transcribing"
DictationProblem
Why dictation stopped or can't run; each is a key in `dictationStrings`.
"unsupported" | "notOnDevice" | "denied" | "noMicrophone" | "noSpeech" | "network" | "language" | "failed" | "transcribeFailed"
DictationOptions
{ /** The text the words go into, usually the composer's `value`. */ value: string /** Called as words arrive, usually the composer's `onChange`. */ onChange: (value: string) => void /** A BCP 47 language such as "en-GB". Left out, LocaleProvider's locale, else the page's `lang`, else the browser's. */ lang?: string | undefined /** Your own speech-to-text. With it, the browser's speech recognition
Dictation
{ status: DictationStatus /** Why it stopped, or why it can't run (with status "unsupported"). Cleared when it starts again. */ problem?: DictationProblem | undefined /** It stopped without a problem, so the words are in the field to check. Cleared when it starts again. */ done: boolean start: () => void stop: () => void toggle: () => void }