{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "speech-input",
  "title": "Speech Input",
  "description": "A press-to-dictate microphone button that fills a text field by voice, and stops claiming to listen the moment the browser has stopped. Reach for it beside any field somebody would rather speak than type: a search box, a comment or reply box, note and journal fields, a message composer, contact and support forms, meeting and consultation notes, inspection and field-service reports filled in on a phone with gloves or dirty hands, delivery and warehouse notes, clinical and veterinary notes, incident and maintenance logs, recipe and shopping lists, long-form description fields in a CMS or listing flow, translation and language-practice inputs, and anywhere dictation is the accessible alternative for someone who cannot comfortably type — motor impairment, RSI, a broken wrist, or simply a phone in one hand. Common asks it answers: \"speech to text react\", \"voice input component\", \"dictation button\", \"react speech recognition component\", \"microphone button for input field\", \"web speech api react hook\", \"voice typing textarea\", \"speech to text search box\", \"react-speech-recognition alternative\", \"useSpeechRecognition hook\", \"voice dictation shadcn\", \"shadcn microphone input\", \"talk to type react\", \"record voice fill form\", \"webkitSpeechRecognition react\", \"speech recognition stops after a few seconds\", \"speech recognition keeps stopping\", \"continuous speech recognition restart onend\", \"speechrecognition onend fires by itself\", \"mic button still says listening after it stopped\", \"speech recognition duplicate words\", \"speech recognition repeats the first word after a pause\", \"resultIndex speech recognition\", \"interim results overwrite the input\", \"webkitSpeechRecognition is not defined\", \"speech recognition not working in firefox\", \"speech recognition safari support\", \"SpeechRecognition not-allowed\", \"microphone permission denied react\", \"allow microphone in an iframe\", \"microphone blocked on http\", \"voice search input react\", \"mic button shadcn\", \"hands free form input\", \"dictate into a textarea\", \"accessible alternative to typing\", \"音声入力 react\", \"音声でテキスト入力\", \"マイクボタン 文字起こし\". Official shadcn/ui has nothing for this and no combination of its parts reaches it: input and textarea are the fields themselves and know nothing about audio, button is a button, and there is no speech, microphone or recording primitive anywhere in the library. Distinct from every other -input component in this registry — otp-input, tag-input, masked-input, phone-input and the rest are the field, while this one sits beside a field you already have and writes into it, so it composes with any of them. The component turns on one fact that every hand-rolled version gets wrong: recognition ends by itself. The browser stops a session after a stretch of silence, and after a while regardless, and the only thing it tells your code is an end event. A button that tracks the boolean its own click set therefore keeps a pulsing red dot and the word Listening over a microphone that was handed back a minute ago, and the user keeps talking into nothing. Here every visible state is driven by the platform's own start, end and error events, the setting the user asked for is kept separate from whether audio is actually being captured (aria-pressed carries the first, data-active the second), and continuous mode starts a fresh session when the browser ends one — under a budget, so a machine with the microphone muted or a laptop that has gone offline cannot turn that into a hot loop, and so a genuine pause in the middle of a paragraph costs nothing. The restart also resets the result cursor, because a new session numbers its results from zero and a cursor carried over from the last one silently swallows the first words after every pause. Interim results are kept strictly out of the value: the service rewrites its guess as more audio arrives, so writing it into the field the user is editing changes their content under them and fills their undo history with words nobody typed — the guess is shown beside the button as a preview and only confirmed text is ever appended. Appending is its own small problem and is solved and exported as appendTranscript, because value + transcript welds every chunk onto the previous word and padding unconditionally with a space yields a stray gap before a dictated full stop. Feature detection reads the prefixed constructor as well as the standard one, since webkitSpeechRecognition is the spelling the browsers that actually ship this expose, and a missing API is a first-class state with its own copy rather than a dead button. The not-allowed error is untangled rather than taken at face value: it means four different things — an http origin, an iframe without allow=\"microphone\", a stored block, and a prompt closed without an answer — and only the third is worth sending someone to their site settings for, so the other three say something true instead, and a permission changed in those settings is picked up live through the Permissions API change event rather than staying dead until a reload. Stop asks the service to deliver what it is still holding instead of aborting and losing the last thing that was said, and unmounting detaches the handlers and aborts, so navigating away inside a single-page app cannot leave the recording indicator lit with no control left that could turn it off. The recognition language is resolved from the document rather than left to the user agent, which is the one setting whose behaviour is not defined across browsers. Worth knowing before shipping it somewhere sensitive: the specification permits the audio to be sent to a remote service — the existence of a network error code is the platform admitting as much — so a dictated field may be data that has left the device. Ships as a hook (useSpeechInput) plus a button, controlled by pairing value with onValueChange or left to report through onTranscript, marked aria-disabled rather than disabled so a refusal stays reachable and can explain itself, with a permanently mounted polite live region for status and failures. Styled entirely with shadcn tokens (input, accent, ring, muted-foreground, destructive), so it follows light and dark mode, and its only dependency is lucide-react for the icons.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/speech-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, Mic, MicOff, TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The bits of the Web Speech API this component actually touches, written out by hand.\n *\n * Two reasons not to lean on the DOM typings. The prefixed constructor — which is still the only\n * one several shipping browsers have — is not declared anywhere, so `window.webkitSpeechRecognition`\n * does not type-check at all. And the unprefixed one drifts: it arrived in `lib.dom.d.ts` only\n * recently, so a consumer on an older TypeScript gets a file that compiles here and not for them.\n * Declaring the surface locally makes the component's dependency on the platform explicit and\n * fixed: these are the properties it sets and the four events it listens to, and nothing else.\n */\ninterface RecognitionAlternative {\n  readonly transcript: string\n  readonly confidence: number\n}\n\ninterface RecognitionResult {\n  readonly isFinal: boolean\n  readonly length: number\n  readonly [index: number]: RecognitionAlternative\n}\n\ninterface RecognitionResultList {\n  readonly length: number\n  readonly [index: number]: RecognitionResult\n}\n\ninterface RecognitionResultEvent {\n  /** Index of the first result this event changed. See the note in `handleResult`. */\n  readonly resultIndex: number\n  /** Every result of the **current session**, not of the recording. That distinction is load-bearing. */\n  readonly results: RecognitionResultList\n}\n\ninterface RecognitionErrorEvent {\n  readonly error: string\n  readonly message?: string\n}\n\ninterface RecognitionInstance {\n  lang: string\n  continuous: boolean\n  interimResults: boolean\n  maxAlternatives: number\n  start(): void\n  stop(): void\n  abort(): void\n  onstart: (() => void) | null\n  onend: (() => void) | null\n  onerror: ((event: RecognitionErrorEvent) => void) | null\n  onresult: ((event: RecognitionResultEvent) => void) | null\n}\n\ntype RecognitionConstructor = new () => RecognitionInstance\n\n/**\n * The constructor, prefixed or not, or null where there isn't one.\n *\n * `webkitSpeechRecognition` is not a legacy alias to be tidied away: at the time of writing it is\n * the spelling Chrome, Edge and Safari expose, and a component that only reads the unprefixed name\n * is a component that does nothing in every browser that has the feature. Firefox has neither\n * unless the user has turned it on themselves, which is why \"unsupported\" below is a first-class\n * state with its own copy rather than an afterthought.\n */\nfunction recognitionConstructorOf(): RecognitionConstructor | null {\n  if (typeof window === \"undefined\") return null\n  const scope = window as unknown as {\n    SpeechRecognition?: RecognitionConstructor\n    webkitSpeechRecognition?: RecognitionConstructor\n  }\n  const ctor = scope.SpeechRecognition ?? scope.webkitSpeechRecognition\n  return typeof ctor === \"function\" ? ctor : null\n}\n\n/** Whether speech recognition exists in this browser at all. */\nexport function isSpeechInputSupported(): boolean {\n  return recognitionConstructorOf() !== null\n}\n\n/**\n * Whether this page is a non-secure context, where the microphone is refused without asking.\n *\n * Worth checking before calling rather than after failing, because the failure is indistinguishable\n * from the user having pressed Block: both arrive as `not-allowed`. On the http staging box on an\n * internal IP no prompt ever appears, and a component that maps that code to \"allow the microphone\n * in your browser settings\" sends everybody hunting for a switch that is not the problem and cannot\n * fix it.\n */\nfunction insecureContext(): boolean {\n  // A browser old enough to lack `isSecureContext` must not be read as insecure — that would refuse\n  // to work on the very browsers this is meant to degrade for.\n  return typeof window !== \"undefined\" && window.isSecureContext === false\n}\n\n/**\n * Whether Permissions Policy forbids the microphone in this document, where that can be asked.\n *\n * The second producer of an unexplained `not-allowed`. The default allowlist for `microphone` is\n * `'self'`, so an `<iframe>` embedding this page without `allow=\"microphone\"` is refused, as is any\n * origin serving `Permissions-Policy: microphone=()`. Neither is something the person looking at\n * the screen can do anything about, and neither shows a prompt.\n *\n * Best effort: the accessor is absent outside Chromium and is not in the DOM typings, so false\n * means \"not known to be blocked\", never \"allowed\".\n */\nfunction blockedByPermissionsPolicy(): boolean {\n  if (typeof document === \"undefined\") return false\n  type Policy = { allowsFeature?: (feature: string) => boolean }\n  const doc = document as Document & { permissionsPolicy?: Policy; featurePolicy?: Policy }\n  const policy = doc.permissionsPolicy ?? doc.featurePolicy\n  if (!policy || typeof policy.allowsFeature !== \"function\") return false\n  try {\n    return !policy.allowsFeature(\"microphone\")\n  } catch {\n    return false\n  }\n}\n\n/**\n * Reads the stored microphone permission without prompting, or null where that cannot be asked.\n *\n * This is what makes it possible to tell the four different things `not-allowed` means apart. The\n * only other way to learn the permission state is to start recognition, and that *acts* — it can\n * put a prompt in front of somebody who never asked for one.\n *\n * Guarded twice: `navigator.permissions` is declared non-optional by TypeScript and is genuinely\n * absent in older browsers, and a browser that has the Permissions API may still not recognise the\n * `microphone` descriptor, in which case `query` rejects instead of answering. Not knowing is an\n * ordinary answer here, and every path below works without it.\n */\nasync function queryMicrophonePermission(): Promise<PermissionStatus | null> {\n  if (typeof navigator === \"undefined\" || !(\"permissions\" in navigator)) return null\n  const permissions: Permissions | undefined = navigator.permissions\n  if (!permissions || typeof permissions.query !== \"function\") return null\n  try {\n    return await permissions.query({ name: \"microphone\" as PermissionName })\n  } catch {\n    return null\n  }\n}\n\n/**\n * The page's own language, which is what dictation should be transcribed in.\n *\n * Left unset, `lang` is resolved by the user agent, and the specification does not pin down how —\n * so the one configuration whose behaviour cannot be predicted across browsers is the one you get\n * by not configuring it. Resolving it here from the document makes the answer defined everywhere:\n * a page that declares `<html lang=\"ja\">` dictates Japanese regardless of what language the\n * browser's own menus are in.\n */\nfunction documentLanguage(): string {\n  if (typeof document !== \"undefined\") {\n    const declared = document.documentElement?.lang\n    if (declared) return declared\n  }\n  if (typeof navigator !== \"undefined\" && navigator.language) return navigator.language\n  return \"en-US\"\n}\n\n/**\n * Why recognition stopped or refused to start.\n *\n * Four of these arrive as the identical `not-allowed` and need four different things from three\n * different people: `denied` is the user's own stored choice, `dismissed` is a prompt closed\n * without an answer, and `insecure-context` and `blocked-by-policy` are the developer's to fix and\n * are invisible to the user.\n */\nexport type SpeechInputFailureCause =\n  | \"unsupported\"\n  | \"insecure-context\"\n  | \"blocked-by-policy\"\n  | \"denied\"\n  | \"dismissed\"\n  | \"service-not-allowed\"\n  | \"no-microphone\"\n  | \"network\"\n  | \"no-speech\"\n  | \"language-not-supported\"\n  | \"bad-grammar\"\n  | \"unknown\"\n\nexport interface SpeechInputFailure {\n  cause: SpeechInputFailureCause\n  /** The browser's own error code, or null when this was settled before recognition started. */\n  code: string | null\n  /** Wording safe to show as-is. Override per cause with the `messages` prop. */\n  message: string\n  /**\n   * Whether pressing the button again, right now, can produce a different result.\n   *\n   * False for `denied` is the point. Once the microphone is blocked for an origin the browser\n   * refuses without prompting, so a \"Try again\" is a button that cannot work — it returns the same\n   * error instantly for as long as the page is open. The only way out runs through the browser's\n   * own site settings, which is what the copy for that case says, and why the permission `change`\n   * listener below exists so that coming back from those settings costs nothing.\n   */\n  retryable: boolean\n}\n\n/**\n * Where recording has got to.\n *\n * `prompting` and `listening` are split because conflating them is the specific lie this component\n * exists to avoid: a button that says \"Listening…\" while the browser's microphone dialog is still\n * sitting there unanswered is describing something that is not happening. `starting` is the same\n * wait where the Permissions API could not tell us a prompt was coming.\n */\nexport type SpeechInputStatus =\n  | \"unsupported\"\n  | \"idle\"\n  | \"prompting\"\n  | \"starting\"\n  | \"listening\"\n  | \"stopping\"\n\n/**\n * The error codes worth starting a fresh session for while the user is still holding the button on.\n *\n * Everything else is a standing condition — no microphone attached, the origin blocked, the\n * language unavailable — where restarting produces the same error immediately and forever. Read\n * synchronously in the `error` handler because `end` follows within the same tick and has to know\n * whether to restart before any async permission lookup could answer.\n */\nconst RESTARTABLE_CODES = new Set([\"no-speech\", \"network\"])\n\nconst RETRYABLE: Record<SpeechInputFailureCause, boolean> = {\n  unsupported: false,\n  \"insecure-context\": false,\n  \"blocked-by-policy\": false,\n  denied: false,\n  dismissed: true,\n  \"service-not-allowed\": false,\n  \"no-microphone\": true,\n  network: true,\n  \"no-speech\": true,\n  \"language-not-supported\": false,\n  \"bad-grammar\": false,\n  unknown: true,\n}\n\nconst DEFAULT_MESSAGES: Record<SpeechInputFailureCause, string> = {\n  unsupported: \"This browser can't transcribe speech.\",\n  \"insecure-context\":\n    \"Dictation needs a secure (https) connection, so the browser refused without asking.\",\n  \"blocked-by-policy\": \"This page isn't permitted to use the microphone.\",\n  denied:\n    \"The microphone is blocked for this site. Allow it in your browser's site settings — this page can't ask again.\",\n  dismissed: \"The microphone request was dismissed.\",\n  \"service-not-allowed\": \"This browser's speech service refused the request.\",\n  \"no-microphone\": \"No microphone was available.\",\n  network: \"The speech service couldn't be reached.\",\n  \"no-speech\": \"Nothing was heard — try speaking again.\",\n  \"language-not-supported\": \"That language isn't available for dictation here.\",\n  \"bad-grammar\": \"The speech grammar couldn't be used.\",\n  unknown: \"Dictation stopped unexpectedly.\",\n}\n\n/** Maps a spec error code to a cause. `not-allowed` is resolved separately; it means four things. */\nfunction causeForCode(code: string): SpeechInputFailureCause {\n  switch (code) {\n    case \"service-not-allowed\":\n      return \"service-not-allowed\"\n    case \"audio-capture\":\n      return \"no-microphone\"\n    case \"network\":\n      return \"network\"\n    case \"no-speech\":\n      return \"no-speech\"\n    case \"language-not-supported\":\n      return \"language-not-supported\"\n    case \"bad-grammar\":\n      return \"bad-grammar\"\n    default:\n      return \"unknown\"\n  }\n}\n\n/**\n * Adds a recognised chunk to text that is already there, the way a person would have typed it.\n *\n * Exported because it is the part a consumer is most likely to want to override, and because it is\n * where the naive version goes wrong. `value + transcript` is what everybody writes first, and it\n * produces \"book a tabletomorrow at six\": the service hands back a bare phrase with no leading\n * space, so every chunk after the first is welded onto the previous word. Padding unconditionally\n * with a space is the other half of the same bug — it yields \"tomorrow .\" for a dictated full stop,\n * and a stray leading space in a field that was empty.\n */\nexport function appendTranscript(base: string, chunk: string): string {\n  const addition = chunk.trim()\n  if (!addition) return base\n  if (!base) return addition\n  if (/\\s$/.test(base)) return base + addition\n  // Punctuation and closing brackets belong to the word before them, never after a space.\n  if (/^[,.!?;:…)\\]}%'\"]/.test(addition)) return base + addition\n  return base + \" \" + addition\n}\n\nexport interface UseSpeechInputOptions {\n  /**\n   * BCP 47 tag to transcribe in. Defaults to the document's own `lang`.\n   *\n   * Applied when a session starts, so changing it mid-recording takes effect on the next one.\n   */\n  lang?: string\n  /**\n   * Keep listening across pauses until stopped, rather than ending after one utterance.\n   *\n   * This is more than the flag of the same name on the platform object. The browser ends a session\n   * on its own — after a stretch of silence, and after a while regardless — so \"keep listening\" is\n   * only true if somebody starts a new session when that happens. That is what the `end` handler\n   * below does, and `maxSilentRestarts` is what stops it spinning.\n   */\n  continuous?: boolean\n  /** Report unconfirmed words as they are heard. On by default; see `interimTranscript`. */\n  interimResults?: boolean\n  /**\n   * How many times in a row a restarted session may produce nothing before dictation gives up.\n   *\n   * The budget exists because the restart loop is otherwise unbounded: a machine with the\n   * microphone muted, or a laptop that has gone offline, ends each session instantly with the same\n   * error, and a handler that restarts on every `end` turns that into a hot loop that burns battery\n   * and, on the browsers whose recognition runs server-side, quota. Any session that produces a\n   * result clears the count, so a real pause in the middle of a paragraph never counts against it.\n   */\n  maxSilentRestarts?: number\n  /** Called with each confirmed chunk, already trimmed. The interim text never reaches this. */\n  onTranscript?: (chunk: string) => void\n  /**\n   * Called when recording starts and stops, including when the browser stops it by itself.\n   *\n   * Not `onChange`: that is a native attribute of `<button>`, so a prop by that name would be\n   * spread onto the element as React's change handler as well as read here.\n   */\n  onRecordingChange?: (recording: boolean) => void\n  /**\n   * Called when a session fails.\n   *\n   * Not `onError`: that is a native DOM attribute React defines on every element, so a prop by that\n   * name collides with it as soon as these options are spread onto the `<button>`.\n   */\n  onFailure?: (failure: SpeechInputFailure) => void\n}\n\nexport interface UseSpeechInputResult {\n  /** Where recording has got to, driven by the browser's own events rather than by the last click. */\n  status: SpeechInputStatus\n  /**\n   * What the user asked for, which is not the same as what the browser is doing.\n   *\n   * Stays true across the gap between one session ending and the next starting, so a continuous\n   * dictation does not flicker. Pair it with `status === \"listening\"` when you want the truth.\n   */\n  isRecording: boolean\n  /** Everything confirmed since recording started. Cleared by `reset()` and by a fresh `start()`. */\n  transcript: string\n  /**\n   * The words currently being guessed at, which the service may still rewrite.\n   *\n   * Kept separate from `transcript` on purpose, and the reason this hook has two strings instead of\n   * one. Interim text is a live guess: \"eight\" becomes \"ate\" becomes \"eighty\" as more audio\n   * arrives. Writing it into the field the user is editing means their content changes under them,\n   * their undo history fills with words nobody typed, and anything they type themselves lands in\n   * the middle of a phrase that is about to be replaced. Show it beside the field as a preview —\n   * that is all it is good for — and let `transcript` be the only thing that ever lands in a value.\n   */\n  interimTranscript: string\n  /** Whether the API exists. Starts true so the server and the first client render agree. */\n  isSupported: boolean\n  /** The last failure, or null. Cleared when recording starts. */\n  failure: SpeechInputFailure | null\n  /** The stored microphone permission, or \"unknown\" where the Permissions API can't say. */\n  permission: PermissionState | \"unknown\"\n  /** Begin recording. Clears the previous transcript. */\n  start: () => void\n  /** Stop, keeping whatever the service is still about to confirm. */\n  stop: () => void\n  /** Stop and throw away anything not yet confirmed. */\n  abort: () => void\n  /** Start or stop. */\n  toggle: () => void\n  /** Empty both transcripts without touching recording. */\n  reset: () => void\n}\n\n/**\n * The whole behaviour, for a control you lay out yourself.\n *\n * One thing to know before shipping this anywhere sensitive: on the browsers that implement speech\n * recognition today it is not necessarily a local computation. The specification allows the audio\n * to be sent to a remote service, and the presence of a `network` error code in the error\n * enumeration is the platform admitting as much — a purely on-device recogniser could not fail that\n * way. Treat a dictated field as data that may have left the device, and say so wherever that\n * matters.\n */\nexport function useSpeechInput({\n  lang,\n  continuous = false,\n  interimResults = true,\n  maxSilentRestarts = 3,\n  onTranscript,\n  onRecordingChange,\n  onFailure,\n}: UseSpeechInputOptions = {}): UseSpeechInputResult {\n  const [status, setStatus] = React.useState<SpeechInputStatus>(\"idle\")\n  const [isRecording, setIsRecording] = React.useState(false)\n  const [transcript, setTranscript] = React.useState(\"\")\n  const [interimTranscript, setInterimTranscript] = React.useState(\"\")\n  const [isSupported, setIsSupported] = React.useState(true)\n  const [failure, setFailure] = React.useState<SpeechInputFailure | null>(null)\n  const [permission, setPermission] = React.useState<PermissionState | \"unknown\">(\"unknown\")\n\n  const recognitionRef = React.useRef<RecognitionInstance | null>(null)\n  // Whether a session is live in the browser. `start()` throws InvalidStateError if one already is,\n  // and the window where that is true is wider than it looks: it stays true after `stop()` until\n  // `end` arrives, which is exactly when an eager restart would fire.\n  const sessionActiveRef = React.useRef(false)\n  // What the user asked for, readable from an event handler that closed over an older render.\n  const intentRef = React.useRef(false)\n  const committedCountRef = React.useRef(0)\n  const silentRestartsRef = React.useRef(0)\n  const producedResultRef = React.useRef(false)\n  const mountedRef = React.useRef(true)\n  const permissionRef = React.useRef<PermissionState | \"unknown\">(\"unknown\")\n  const failureRef = React.useRef<SpeechInputFailure | null>(null)\n  // The restart path reaches the session opener through this rather than by name. Two reasons: the\n  // handlers are attached once and would otherwise pin the first render's closure forever, and a\n  // `useCallback` that names itself inside its own initializer has no inferable type.\n  const beginSessionRef = React.useRef<(() => void) | null>(null)\n\n  const langRef = React.useRef(lang)\n  const continuousRef = React.useRef(continuous)\n  const interimResultsRef = React.useRef(interimResults)\n  const maxSilentRestartsRef = React.useRef(maxSilentRestarts)\n  const onTranscriptRef = React.useRef(onTranscript)\n  const onRecordingChangeRef = React.useRef(onRecordingChange)\n  const onFailureRef = React.useRef(onFailure)\n\n  // Declared before everything that reads it: under StrictMode the cleanups run and the effects run\n  // again, and if this came last the second pass would see `false` and quietly refuse to work.\n  React.useEffect(() => {\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  React.useEffect(() => {\n    langRef.current = lang\n    continuousRef.current = continuous\n    interimResultsRef.current = interimResults\n    maxSilentRestartsRef.current = maxSilentRestarts\n    onTranscriptRef.current = onTranscript\n    onRecordingChangeRef.current = onRecordingChange\n    onFailureRef.current = onFailure\n  }, [\n    continuous,\n    interimResults,\n    lang,\n    maxSilentRestarts,\n    onFailure,\n    onRecordingChange,\n    onTranscript,\n  ])\n\n  React.useEffect(() => {\n    const supported = isSpeechInputSupported()\n    setIsSupported(supported)\n    if (!supported) setStatus(\"unsupported\")\n  }, [])\n\n  const setRecording = React.useCallback((next: boolean) => {\n    if (intentRef.current === next) return\n    intentRef.current = next\n    setIsRecording(next)\n    onRecordingChangeRef.current?.(next)\n  }, [])\n\n  const fail = React.useCallback((cause: SpeechInputFailureCause, code: string | null) => {\n    const next: SpeechInputFailure = {\n      cause,\n      code,\n      message: DEFAULT_MESSAGES[cause],\n      retryable: RETRYABLE[cause],\n    }\n    failureRef.current = next\n    setFailure(next)\n    onFailureRef.current?.(next)\n  }, [])\n\n  /**\n   * Reads the stored permission and keeps the dead end honest.\n   *\n   * The `change` event is why this is a subscription rather than one read. Somebody sent to site\n   * settings by the `denied` message comes back to a page that is still open, and the browser fires\n   * `change` the moment they flip the switch. Handling it is what turns \"allow it in your settings\"\n   * into advice that visibly works; ignoring it leaves the button dead until a reload, which is the\n   * point at which people conclude the site is broken.\n   */\n  React.useEffect(() => {\n    let statusHandle: PermissionStatus | null = null\n    let cancelled = false\n\n    const handleChange = () => {\n      if (!statusHandle) return\n      const next = statusHandle.state\n      setPermission(next)\n      permissionRef.current = next\n      if (next === \"denied\") return\n      // No longer blocked, so a `denied` message on screen is now false and leaving it there is\n      // worse than never having shown it. Only that one is cleared: a network error or a language\n      // that is unavailable has nothing to do with permission and is still true.\n      if (failureRef.current?.cause === \"denied\") {\n        failureRef.current = null\n        setFailure(null)\n      }\n    }\n\n    void queryMicrophonePermission().then((result) => {\n      if (cancelled || !result) return\n      statusHandle = result\n      setPermission(result.state)\n      permissionRef.current = result.state\n      result.addEventListener(\"change\", handleChange)\n    })\n\n    return () => {\n      cancelled = true\n      statusHandle?.removeEventListener(\"change\", handleChange)\n    }\n  }, [])\n\n  const beginSession = React.useCallback(() => {\n    const ctor = recognitionConstructorOf()\n    if (!ctor) {\n      setIsSupported(false)\n      setStatus(\"unsupported\")\n      setRecording(false)\n      fail(\"unsupported\", null)\n      return\n    }\n    // Both of these produce `not-allowed` with no prompt and no way for the user to help. Settled\n    // here, before starting, so the reason survives instead of being flattened into a code that\n    // means four things.\n    if (insecureContext()) {\n      setRecording(false)\n      setStatus(\"idle\")\n      fail(\"insecure-context\", null)\n      return\n    }\n    if (blockedByPermissionsPolicy()) {\n      setRecording(false)\n      setStatus(\"idle\")\n      fail(\"blocked-by-policy\", null)\n      return\n    }\n    if (sessionActiveRef.current) return\n\n    let recognition = recognitionRef.current\n    if (!recognition) {\n      recognition = new ctor()\n      recognitionRef.current = recognition\n\n      recognition.onstart = () => {\n        if (!mountedRef.current) return\n        // Reset here rather than where `start()` is called, because this is the moment the session's\n        // result list begins. A restarted session hands back a brand-new list numbered from zero,\n        // and a cursor carried over from the session before it would skip that many results of the\n        // new one — the failure being a dictation that silently drops its first few words every\n        // time the browser has paused and been restarted.\n        committedCountRef.current = 0\n        producedResultRef.current = false\n        setStatus(\"listening\")\n      }\n\n      recognition.onresult = (event) => {\n        if (!mountedRef.current) return\n        producedResultRef.current = true\n\n        // `results` holds every result of the session so far, and `resultIndex` says where this\n        // event's changes begin — but neither is a cursor for \"what has been taken already\", which\n        // is what appending needs. Walking from `resultIndex` each time re-reads results that were\n        // already confirmed and appends them twice; walking from zero and rebuilding throws away\n        // anything the consumer has edited in between. So the cursor is kept here, and only moves\n        // over results that are final.\n        let cursor = committedCountRef.current\n        const confirmed: string[] = []\n        let pending = \"\"\n        // Once an unconfirmed result is reached the cursor stops, even if a later one is already\n        // final. Advancing past a gap would strand the result in it: it becomes final a moment\n        // later, by which time the cursor is beyond it and it is never taken.\n        let stillConfirming = true\n\n        for (let index = cursor; index < event.results.length; index += 1) {\n          const result = event.results[index]\n          const text = result?.[0]?.transcript ?? \"\"\n          if (result?.isFinal && stillConfirming) {\n            // Kept as separate chunks rather than concatenated. One event can confirm more than one\n            // result, and joining them with `+` here welds the last word of each onto the first word\n            // of the next — the same defect `appendTranscript` exists to prevent, reintroduced one\n            // level down where that function never gets to see it.\n            const chunk = text.trim()\n            if (chunk) confirmed.push(chunk)\n            cursor = index + 1\n          } else {\n            stillConfirming = false\n            pending = appendTranscript(pending, text)\n          }\n        }\n\n        committedCountRef.current = cursor\n        setInterimTranscript(pending)\n\n        if (confirmed.length === 0) return\n        silentRestartsRef.current = 0\n        setTranscript((previous) => confirmed.reduce(appendTranscript, previous))\n        for (const chunk of confirmed) onTranscriptRef.current?.(chunk)\n      }\n\n      recognition.onerror = (event) => {\n        if (!mountedRef.current) return\n        const code = event?.error ?? \"unknown\"\n        // We caused this one — `abort()`, an unmount, a navigation. Reporting it would put an error\n        // on screen every time somebody pressed stop.\n        if (code === \"aborted\") return\n\n        // Decided synchronously, because `end` follows in the same tick and needs to know whether\n        // to start another session before any permission lookup could answer.\n        if (!RESTARTABLE_CODES.has(code)) setRecording(false)\n\n        if (code === \"not-allowed\") {\n          // Checked again here, not just before starting: a document can be moved into a frame that\n          // forbids the microphone between the two moments, and a browser that reached this path\n          // without being able to run the pre-checks still deserves the right answer.\n          if (insecureContext()) return fail(\"insecure-context\", code)\n          if (blockedByPermissionsPolicy()) return fail(\"blocked-by-policy\", code)\n          void queryMicrophonePermission().then((result) => {\n            if (!mountedRef.current) return\n            if (!result) {\n              // Nothing can tell these apart here, so the copy for `denied` carries the day: it is\n              // the only one of the two that stays broken, and advice to check site settings is\n              // harmless to somebody who merely closed the dialog.\n              return fail(\"denied\", code)\n            }\n            setPermission(result.state)\n            permissionRef.current = result.state\n            if (result.state === \"denied\") return fail(\"denied\", code)\n            // Refused while the stored answer is \"granted\" can only come from above the user — a\n            // frame or a header — so sending them to their own settings would be wrong.\n            if (result.state === \"granted\") return fail(\"blocked-by-policy\", code)\n            // Still \"prompt\", so nothing was stored and the dialog was closed rather than answered.\n            // This is the one refusal worth offering a retry for, and asking again really does\n            // re-prompt.\n            fail(\"dismissed\", code)\n          })\n          return\n        }\n\n        // Silence during a continuous dictation is not an error the user needs to see — it is a\n        // pause. It only becomes one when the restart budget runs out, and `end` reports it then.\n        if (code === \"no-speech\" && continuousRef.current && intentRef.current) return\n\n        fail(causeForCode(code), code)\n      }\n\n      recognition.onend = () => {\n        sessionActiveRef.current = false\n        if (!mountedRef.current) return\n        // An interim result that never became final is gone: the session that was guessing at it\n        // has ended, and the next one starts from an empty list.\n        setInterimTranscript(\"\")\n\n        if (!intentRef.current) {\n          setStatus(\"idle\")\n          return\n        }\n\n        // The whole reason this component exists. The browser ends a session on its own — after a\n        // stretch of silence, and after a while regardless — and says nothing to your code beyond\n        // this event. A control that tracked only its own clicks is still showing a red dot and the\n        // word \"Listening\" at this point, over a microphone that was handed back some time ago.\n        if (!continuousRef.current) {\n          setRecording(false)\n          setStatus(\"idle\")\n          return\n        }\n\n        if (!producedResultRef.current) silentRestartsRef.current += 1\n        if (silentRestartsRef.current > maxSilentRestartsRef.current) {\n          setRecording(false)\n          setStatus(\"idle\")\n          if (!failureRef.current) fail(\"no-speech\", null)\n          return\n        }\n        setStatus(\"starting\")\n        // Deferred out of this handler rather than called from inside it. Starting a session from\n        // within the previous session's own `end` is the one ordering the browser is entitled to\n        // reject — it has not necessarily finished tearing the old one down — and `start()` throwing\n        // there would end the dictation at the first pause. A microtask is enough to be outside it\n        // while still being the same beat as far as the user is concerned. Re-checked on arrival:\n        // the component can have been unmounted, or stop pressed, in between.\n        void Promise.resolve().then(() => {\n          if (!mountedRef.current || !intentRef.current) return\n          beginSessionRef.current?.()\n        })\n      }\n    }\n\n    recognition.lang = langRef.current ?? documentLanguage()\n    recognition.continuous = continuousRef.current\n    recognition.interimResults = interimResultsRef.current\n    recognition.maxAlternatives = 1\n\n    // Set before the call, not after: in a browser `start()` returns long before any event, but\n    // ordering the two the other way round makes the component depend on that being true, and a\n    // `start` delivered synchronously would have its \"listening\" overwritten with \"starting\".\n    //\n    // \"prompt\" is the one state that reliably means a dialog is about to appear. Where the\n    // Permissions API could not answer, claiming to know would be the lie.\n    setStatus(permissionRef.current === \"prompt\" ? \"prompting\" : \"starting\")\n    try {\n      recognition.start()\n      sessionActiveRef.current = true\n    } catch {\n      // InvalidStateError: the browser still considers a session live even though `end` has not\n      // reached us. Nothing is broken and nothing is owed — the session that is already running is\n      // the one that was wanted.\n      sessionActiveRef.current = true\n    }\n  }, [fail, setRecording])\n\n  React.useEffect(() => {\n    beginSessionRef.current = beginSession\n  }, [beginSession])\n\n  const start = React.useCallback(() => {\n    if (intentRef.current) return\n    failureRef.current = null\n    setFailure(null)\n    setTranscript(\"\")\n    setInterimTranscript(\"\")\n    silentRestartsRef.current = 0\n    producedResultRef.current = false\n    setRecording(true)\n    beginSession()\n  }, [beginSession, setRecording])\n\n  const stop = React.useCallback(() => {\n    if (!intentRef.current) return\n    setRecording(false)\n    if (!sessionActiveRef.current) {\n      setStatus(\"idle\")\n      return\n    }\n    setStatus(\"stopping\")\n    // `stop()` rather than `abort()`: the difference is a whole sentence. Stopping asks the service\n    // to finish what it is holding and deliver it as a final result; aborting throws it away. A\n    // button that aborts loses the last thing the user said, every time, which reads as the\n    // component dropping words at random.\n    try {\n      recognitionRef.current?.stop()\n    } catch {\n      // Already stopping or already stopped.\n    }\n  }, [setRecording])\n\n  const abort = React.useCallback(() => {\n    setRecording(false)\n    setInterimTranscript(\"\")\n    setStatus(\"idle\")\n    try {\n      recognitionRef.current?.abort()\n    } catch {\n      // Nothing was running.\n    }\n  }, [setRecording])\n\n  const toggle = React.useCallback(() => {\n    if (intentRef.current) stop()\n    else start()\n  }, [start, stop])\n\n  const reset = React.useCallback(() => {\n    setTranscript(\"\")\n    setInterimTranscript(\"\")\n  }, [])\n\n  // Handing the microphone back is not optional and nothing else will do it. The recognition object\n  // is owned by the page, not by this component, so navigating from the form to the confirmation\n  // screen inside a single-page app would otherwise leave the browser recording — with the\n  // recording indicator lit in the tab strip and no control left anywhere that could stop it.\n  React.useEffect(() => {\n    return () => {\n      const recognition = recognitionRef.current\n      recognitionRef.current = null\n      if (!recognition) return\n      // Detached before aborting, not after: `abort()` synchronously fires `error` and `end`, and\n      // handlers still attached at that moment would run after the component is gone.\n      recognition.onstart = null\n      recognition.onresult = null\n      recognition.onerror = null\n      recognition.onend = null\n      try {\n        recognition.abort()\n      } catch {\n        // Nothing was running.\n      }\n    }\n  }, [])\n\n  return {\n    status,\n    isRecording,\n    transcript,\n    interimTranscript,\n    isSupported,\n    failure,\n    permission,\n    start,\n    stop,\n    abort,\n    toggle,\n    reset,\n  }\n}\n\nexport interface SpeechInputButtonProps\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\" | \"value\" | \"onChange\">,\n    UseSpeechInputOptions {\n  /**\n   * The field's current text, when you want the button to fill it for you.\n   *\n   * Pass this together with `onValueChange` and confirmed speech is appended to whatever is already\n   * there, spaced the way `appendTranscript` describes — so the same state that backs your\n   * `<input>` backs the dictation, and typing and speaking can be mixed in one field. Leave both\n   * off and the button only reports through `onTranscript`.\n   */\n  value?: string\n  /** Called with the field's new text. Only ever carries confirmed speech, never the interim guess. */\n  onValueChange?: (value: string) => void\n  /**\n   * The button's accessible name, which does not change while recording.\n   *\n   * Deliberately unlike the sibling `geolocation-button`, whose label reports what it is doing. This\n   * one is a toggle, and the convention for a toggle is a name that stays put while `aria-pressed`\n   * carries the state — a name that flips to \"Stop dictating\" is announced as a different control\n   * appearing, and a user who has just been told \"Dictate, pressed\" hears the contradiction. The\n   * state reaches everyone else through the live region below.\n   */\n  label?: string\n  /** Name used where the API is missing. Kept as the accessible name so the control explains itself. */\n  unsupportedLabel?: string\n  /** Announced while the browser's microphone dialog is open. */\n  promptingLabel?: string\n  /** Announced between the request and the microphone actually opening. */\n  startingLabel?: string\n  /** Announced while the microphone is live. */\n  listeningLabel?: string\n  /** Announced while the last words are being confirmed. */\n  stoppingLabel?: string\n  /** Drop the visible text and keep it as the accessible name. */\n  iconOnly?: boolean\n  /** Hide the status line. It stays in the live region either way. */\n  hideMessage?: boolean\n  /** Show the unconfirmed words beside the button while they are being guessed at. */\n  showInterim?: boolean\n  /** Per-cause wording, merged over the defaults. */\n  messages?: Partial<Record<SpeechInputFailureCause, string>>\n  /** Class for the wrapper. `className` goes to the button. */\n  containerClassName?: string\n}\n\n/**\n * A press-to-dictate button that stops claiming to listen the moment it isn't.\n */\nexport function SpeechInputButton({\n  value,\n  onValueChange,\n  label = \"Dictate\",\n  unsupportedLabel = \"Dictation isn't supported here\",\n  promptingLabel = \"Waiting for microphone permission…\",\n  startingLabel = \"Starting…\",\n  listeningLabel = \"Listening…\",\n  stoppingLabel = \"Finishing…\",\n  iconOnly = false,\n  hideMessage = false,\n  showInterim = true,\n  messages,\n  containerClassName,\n  lang,\n  continuous,\n  interimResults,\n  maxSilentRestarts,\n  onTranscript,\n  onRecordingChange,\n  onFailure,\n  onClick,\n  className,\n  ...props\n}: SpeechInputButtonProps) {\n  // Read through a ref so the transcript handler appends to the text as it is *now*. Closing over\n  // the prop instead would append to whatever the value was when recording started, so every chunk\n  // after the first would wipe out the one before it.\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    valueRef.current = value\n  }, [value])\n\n  const onValueChangeRef = React.useRef(onValueChange)\n  const onTranscriptRef = React.useRef(onTranscript)\n  React.useEffect(() => {\n    onValueChangeRef.current = onValueChange\n    onTranscriptRef.current = onTranscript\n  }, [onTranscript, onValueChange])\n\n  const handleTranscript = React.useCallback((chunk: string) => {\n    onTranscriptRef.current?.(chunk)\n    const change = onValueChangeRef.current\n    if (!change) return\n    const next = appendTranscript(valueRef.current ?? \"\", chunk)\n    // Kept in step immediately: two chunks can arrive before the parent has re-rendered with the\n    // new value, and the second would otherwise be appended to the stale one.\n    valueRef.current = next\n    change(next)\n  }, [])\n\n  const { status, isRecording, interimTranscript, isSupported, failure, toggle } = useSpeechInput({\n    lang,\n    continuous,\n    interimResults,\n    maxSilentRestarts,\n    onTranscript: handleTranscript,\n    onRecordingChange,\n    onFailure,\n  })\n\n  // Referenced by id from the button, so it needs one that survives hydration.\n  const messageId = React.useId()\n\n  const busy = status === \"prompting\" || status === \"starting\" || status === \"stopping\"\n  // A failure marked non-retryable keeps the control reachable and says why, rather than dangling\n  // an action that cannot work. Recording is always stoppable, whatever went wrong.\n  const actionable = isSupported && (isRecording || failure?.retryable !== false)\n\n  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    onClick?.(event)\n    if (event.defaultPrevented || !actionable) return\n    toggle()\n  }\n\n  const statusMessage =\n    status === \"prompting\"\n      ? promptingLabel\n      : status === \"starting\"\n        ? startingLabel\n        : status === \"listening\"\n          ? listeningLabel\n          : status === \"stopping\"\n            ? stoppingLabel\n            : \"\"\n\n  // A failure outranks the status line, except while recording is under way again — at which point\n  // the old message describes something that is no longer true.\n  const message = failure && !statusMessage ? (messages?.[failure.cause] ?? failure.message) : statusMessage\n\n  const accessibleName = isSupported ? label : unsupportedLabel\n  const Icon = busy ? Loader2 : !isSupported ? MicOff : failure && !failure.retryable ? TriangleAlert : Mic\n\n  return (\n    <div className={cn(\"flex flex-col items-start gap-2\", containerClassName)}>\n      <div className=\"flex items-center gap-2\">\n        <button\n          type=\"button\"\n          onClick={handleClick}\n          // The setting the user operates, which is what a toggle announces. An icon swap is not\n          // something a screen reader reports, so it cannot be the only signal that this is on.\n          aria-pressed={isRecording}\n          // `aria-disabled` rather than `disabled`: a real disabled button leaves the tab order, so\n          // a keyboard or screen-reader user never reaches it and never hears why dictation is\n          // unavailable. The click handler above is what refuses.\n          aria-disabled={actionable ? undefined : true}\n          aria-busy={busy || undefined}\n          aria-label={iconOnly ? accessibleName : undefined}\n          // Points at the status line so the reason is part of the button's description wherever\n          // one exists, rather than only being announced once as it appears.\n          aria-describedby={message ? messageId : undefined}\n          data-status={status}\n          // The honest one, for styling or a test: `aria-pressed=\"true\"` with `data-active=\"false\"`\n          // is a dictation the browser has quietly stopped and is about to resume.\n          data-active={status === \"listening\" ? \"true\" : \"false\"}\n          data-cause={failure?.cause}\n          className={cn(\n            \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:border-destructive/60 aria-pressed:text-destructive\",\n            iconOnly ? \"w-9\" : \"px-4\",\n            className\n          )}\n          {...props}\n        >\n          <Icon\n            className={cn(\n              \"h-4 w-4\",\n              busy && \"animate-spin\",\n              status === \"listening\" && \"animate-pulse\"\n            )}\n            aria-hidden=\"true\"\n          />\n          {iconOnly ? null : accessibleName}\n        </button>\n        {/*\n          The live guess, shown and deliberately not announced. It is rewritten several times a\n          second as more audio arrives, and a polite live region over it would queue every revision\n          and read them all — which is both useless and impossible to interrupt. The confirmed text\n          lands in the field, where a screen reader reports it the same way it reports typing.\n        */}\n        {showInterim && interimTranscript ? (\n          <span className=\"truncate text-sm italic text-muted-foreground\" data-interim=\"true\">\n            {interimTranscript}\n          </span>\n        ) : null}\n      </div>\n      {/*\n        Mounted from the start and left empty, never conditionally rendered. A live region inserted\n        into the document already holding its text is not reliably announced — the region has to\n        exist for the browser to notice the text changing inside it — so the version that only\n        appears when something happens is silent for exactly the users depending on it.\n      */}\n      <p\n        id={messageId}\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\"text-sm text-muted-foreground\", (hideMessage || !message) && \"sr-only\")}\n      >\n        {message}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
