AI

Dictation Button

A composer mic that turns speech into committed text — toggle or hold-to-talk, an amplitude ring, a live interim chip, and exactly one transcript per session.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { LoaderCircle, Mic, MicOff, Square, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Web Speech typings
 *
 * Declared LOCALLY and under different names than the platform ones. A
 * `declare global { interface Window { SpeechRecognition: … } }` collides the
 * moment a TypeScript release ships its own definition ("subsequent property
 * declarations must have the same type"), and this file has to compile in every
 * consumer's project, not just in ours.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dictation-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "DictationButton": a microphone control
for a composer that turns speech into text and hands that text over exactly once
per session. lucide-react for glyphs, no other runtime dependency. The browser's
SpeechRecognition is optional — the same component must run on somebody else's
ASR without changing a single visual.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children. The root is a column: preview chip, button, hint line.
- mode?: "toggle" | "hold" (default "toggle").
- engine?: "speech" | "external" (default "speech").
- speechLang?: string — BCP-47 for the built-in recognizer. NOT named `lang`:
  that would shadow the root element's own attribute.
- status?: DictationStatus, onStatusChange?: (s) => void — controlled or not.
  DictationStatus = "idle" | "requesting" | "listening" | "denied" |
  "unsupported" | "error".
- interim?: string — external engine only: everything recognised in the current
  session (finals plus the partial tail). It feeds the chip and it IS what gets
  committed on release.
- onInterim?: (text: string) => void — speech engine only, every partial.
- onTranscript?: (text: string) => void — the committed text. Never empty,
  never twice for one session, never on cancel.
- onStart?: () => void, onStop?: () => void — session opened / settled.
- level?: number (0..1) — amplitude for the ring. Omit for the built-in
  envelope.
- preview?: boolean = true, previewSide?: "top" | "bottom" = "top".
- hints?: boolean = true — inline one-liners for denied / unsupported / error /
  mis-tap.
- maxDuration?: number = 60000 — hot-mic ceiling in ms; 0 disables.
- size?: "sm" | "md" | "lg" = "md", label?: string, disabled?: boolean.

Behavior — the session machine (one machine, both engines)
- idle -> requesting -> listening -> idle, with denied / unsupported / error as
  terminal side branches that stay put until the user retries.
- "requesting" exists because the permission prompt is a real state: the engine
  has been asked, the mic is not hot yet. Only the recognizer's start event
  promotes it to "listening". In external mode there is no prompt to wait for,
  so opening the session goes straight to "listening".
- Commit sits behind a ONE-SHOT LOCK, not behind six careful conditions. A
  session can end from six places — second activation, hold release,
  maxDuration, document hidden, Escape, unmount — and onTranscript firing twice
  would duplicate the user's sentence in their composer. Initialise the lock to
  "settled": before the first session there is nothing to commit, so a stray
  release fires no callbacks at all.
- Cancel (Escape, and a hold shorter than 350 ms) settles the session WITHOUT
  calling onTranscript. Commit and cancel are different verbs; conflating them
  is how dictation UIs paste half a second of room tone into a text field.
- Escape is listened for on the window, not on the button: while dictating, the
  caret is normally in the composer, not on the mic.
- A document that becomes hidden commits and stops. A recording indicator lit in
  a tab nobody is looking at is indistinguishable from spyware.
- maxDuration commits the way a second click would — it is a ceiling, not a
  discard: whatever was heard is still the user's.

Behavior — the built-in recognizer
- Feature-detect SpeechRecognition ?? webkitSpeechRecognition through
  useSyncExternalStore with an OPTIMISTIC server snapshot (true), so the
  crossed-out mic never flashes for the majority who do have an engine. Never
  read `window` during render.
- Declare the Web Speech types LOCALLY, under your own names. A
  `declare global { interface Window { SpeechRecognition: … } }` collides with
  whatever lib.dom ships in the consumer's TypeScript version.
- continuous = true, interimResults = true, maxAlternatives = 1.
- Rebuild the transcript from results[0..n] on every result event; do NOT append
  from resultIndex. Engines are allowed to revise a result they already
  delivered, and appending doubles every revised phrase.
- A continuous session ends BY ITSELF after a pause in speech. If the user has
  not asked to stop, fold the finished instance's text into a carry buffer,
  reset the per-instance buffers, and open a NEW recognizer after ~120 ms — that
  restart chain is what makes dictation feel continuous. Budget it: a restart
  that produced words resets the counter, a run of ~8 EMPTY restarts settles the
  session normally. Silence is not a failure and must never paint the error
  state.
- Error mapping: "aborted" is your own stop() landing on a handler you could not
  detach — ignore it. "no-speech" is silence; let the end event decide.
  "not-allowed" / "service-not-allowed" -> denied, discard, inline hint.
  Everything else -> error, and still COMMIT what was recognised before the
  failure.
- Stopping gracefully means stop() — which flushes one last final result — and
  committing from the end event, with a ~700 ms fallback timer for engines that
  never get there. The one-shot lock makes that race harmless.
- Guard every handler with a stale() check on a session counter AND on instance
  identity, so a superseded recognizer can never write state.
- Unmount: lock the commit FIRST (a callback fired from a cleanup setStates into
  a tree that is already gone), then detach handlers, then abort — detaching
  before aborting matters, because abort fires the end event and a live handler
  would restart. Aborting is what releases the capture track and turns the
  browser's recording indicator off.

Behavior — the two gestures
- Toggle: activate to open, activate again to commit; aria-pressed reflects it.
- Hold: pointerdown opens, pointerup / pointercancel / lostpointercapture
  commits. Call setPointerCapture so a release outside the button still lands,
  and let ONLY the captured pointer id end the session — a second finger must
  not cut the first one short. touch-action: none, or the press scrolls the page
  out from under the thumb.
- A hold shorter than 350 ms is a mis-tap: discard and hint "press and hold".
- Keyboard hold: keydown (ignore repeat) opens, keyup commits, and BOTH
  preventDefault so the browser does not synthesise a third activation. No
  minimum duration for keys — a keyboard user cannot "hold longer" on demand,
  and losing focus mid-press commits rather than hanging.
- Synthesised activations are the accessibility trap here: a screen reader
  dispatches a bare click, never pointerdown/pointerup. In hold mode, treat a
  click with detail === 0 and no press in flight as a TOGGLE, otherwise those
  users arm a mic they can never close. Ignore any click within ~500 ms of a
  real release as well — that one is the touch compatibility click, whatever
  `detail` the platform decides to report.

Behavior — preview and amplitude
- The chip shows the TAIL of the transcript (~84 chars, cut at a word boundary,
  leading ellipsis): the newest words are the ones the user is checking.
- The chip is NOT a live region. Announcing every partial restarts a screen
  reader mid-word dozens of times a minute. A separate sr-only role="status"
  region announces transitions instead — listening, cancelled, nothing heard,
  and the committed text once — and is cleared after ~4 s so the same message
  can be announced twice in a row.
- The ring is written straight to the node's style.transform inside a rAF loop,
  never through setState: sixty re-renders a second of the surrounding composer
  row is too much to pay for a decorative circle.
- With no level prop, drive it from a deterministic three-sine envelope
  (syllables riding on words riding on breaths). Deterministic, so a screenshot
  of the listening state is stable and no RNG is involved.
- prefers-reduced-motion: the simulated envelope does not run at all — the ring
  is pinned to a static, clearly visible scale. A REAL level keeps driving it,
  quantised to four steps with no transition, because measured amplitude is
  information rather than decoration. Dictation works identically either way.

Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground while listening,
  bg-muted + text-muted-foreground idle, hover:bg-accent +
  hover:text-accent-foreground, bg-destructive/10 + text-destructive for denied
  and error, bg-popover / text-popover-foreground + border for the chip,
  bg-primary/15 and border-primary/40 for the ring, ring-ring for focus. No hex,
  no rgb(), no oklch().
- Merge every className through cn(). The root carries data-status and data-mode
  so a parent can style around the session without prop drilling.
- Circle sizes 36 / 44 / 56 px. The glyph swaps to a stop square in toggle mode
  while listening, a spinner while requesting, a crossed mic for denied and
  unsupported, a warning triangle for error.
- "No engine in this browser" uses aria-disabled, not the disabled attribute:
  the button keeps its focus stop and activating it explains WHY, instead of
  silently swallowing the interaction. The real disabled prop stays reserved for
  the owner turning the control off.
- The chip keyframes ship with the component through a React 19 hoisted
  <style href precedence>, and the pulse is motion-reduce:[animation:none].

Customization levers
- Gesture: mode is the axis. Toggle for desktop composers, hold for mobile and
  for a walkie-talkie feel. The 350 ms mis-tap threshold is a constant — raise
  it for gloves and cold hands, drop it to 0 to commit every press.
- Amplitude source: pass level from an AnalyserNode (getByteTimeDomainData ->
  RMS -> 0..1) to make the ring truthful, or leave it off for the envelope. The
  RING_GAIN constant (0.55) is how far the ring travels at level 1.
- Preview: preview={false} for a bare mic in a dense toolbar; previewSide
  "bottom" when the composer already owns the space above. Wrap the root in a
  relative box and absolutely position the chip if you need zero layout shift.
- Text policy: the built-in flow commits finals + partial. Drop the partial if
  your engine is noisy, or route onInterim into the composer for live insertion
  and use onTranscript only to finalise.
- Copy: every string lives in three module-scope records (labels, hints,
  announcements). Translating the control means editing them, not threading a
  labels object through the props.
- Timings: 700 ms flush, 120 ms restart delay, 8 empty restarts, 60 s
  maxDuration, 4 s announcement, 6 s hint — all named constants at the top.
- Skinning: the ring is two absolutely positioned circles. Swap them for bars, a
  blurred halo or a conic sweep without touching the machine underneath.

Concepts

  • One-shot commit — six different events can end a dictation session, so the hand-off sits behind a single boolean lock rather than behind six correct conditions; the lock starts closed, which is why a stray release before the first session fires nothing at all.
  • Cancel is not commit — Escape and a sub-350 ms hold settle the session and throw the words away, because a mic that pastes half a second of room tone into a composer is worse than a mic that does nothing.
  • Restart carry — a continuous recognizer hangs up by itself after a pause in speech; the finished instance's words are folded into a carry buffer before a new recognizer opens, which is what turns a chain of engine sessions into one user session. Empty restarts are budgeted; productive ones reset the budget.
  • Rebuild, never append — partial results may be revised after they were delivered, so the transcript is rebuilt from the whole result list on every event; appending from resultIndex silently doubles every corrected phrase.
  • Quiet preview — the interim chip is deliberately not a live region: it shows the newest ~84 characters for the eye, while a separate polite status region announces only transitions and the final text, so a screen reader is never restarted mid-word by a partial.
  • Synthesised activation — assistive tech dispatches a bare click with no press and no release, so a press-and-hold control that listens only to pointer and key events is unusable with a screen reader; a click with detail === 0 therefore falls back to toggle semantics.

On This Page