{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-input","type":"registry:component","title":"Voice input","description":"Dictate a prompt: a waveform from your real mic level, a timer, then transcribing.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/voice-input.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Mic, MicOff, RotateCcw, Square, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * VOICE INPUT: dictating a prompt\n *\n *   idle          a mic button\n *   listening     the button stretches into a bar: the mic blurs\n *                 into Stop, a waveform drawn from your mic level,\n *                 a timer whose seconds roll, and Cancel; the live\n *                 transcript opens above\n *   transcribing  the waveform settles flat as \"Transcribing\"\n *                 rises over it and Stop becomes a spinner; then\n *                 the bar shrinks back into the mic\n *   blocked       the browser denied the microphone; the mic blurs\n *                 into a crossed-out one and how to fix it opens in\n *   error         it didn't catch that; try again\n *\n * One surface throughout: give it the width the bar should fill\n * and the mic stretches across it from its own side (align=\"end\"\n * for a mic beside Send, so it grows leftwards). Your recording\n * code owns the mic: pass\n * status and the current level (0 to 1). Without a level the\n * bars move in a gentle wave.\n * ───────────────────────────────────────────────────────── */\n\nexport type VoiceStatus = \"idle\" | \"listening\" | \"transcribing\" | \"blocked\" | \"error\";\n\nexport interface VoiceInputProps extends React.HTMLAttributes<HTMLDivElement> {\n  status: VoiceStatus;\n  /** Current microphone level, 0 to 1. */\n  level?: number;\n  /** The words recognised so far. */\n  transcript?: string;\n  /** When listening started (ms); shows a timer. */\n  startedAt?: number;\n  onStart?: () => void;\n  onStop?: () => void;\n  onCancel?: () => void;\n  errorText?: string;\n  /** Which side the mic sits on; the bar grows from there. \"end\" suits a mic beside Send. */\n  align?: \"start\" | \"end\";\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst BARS = 28;\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\nconst TWO_DIGITS = { minimumIntegerDigits: 2 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** Content that fades in once its surface has made room, and out before it closes. */\nconst appear = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  filter: on ? \"none\" : \"blur(4px)\",\n  transition: reduced ? \"none\" : on ? `opacity 320ms ${MORPH} 140ms, filter 320ms ${MORPH} 140ms` : `opacity 140ms ease-out, filter 140ms ease-out`,\n});\n\nexport function VoiceInput({\n  status,\n  level,\n  transcript,\n  startedAt,\n  onStart,\n  onStop,\n  onCancel,\n  errorText,\n  align = \"start\",\n  className = \"\",\n  ...props\n}: VoiceInputProps) {\n  const end = align === \"end\";\n  const reduced = useReducedMotion();\n  const listening = status === \"listening\";\n  const transcribing = status === \"transcribing\";\n  const wide = listening || transcribing;\n  const alert = status === \"blocked\" || status === \"error\";\n  const blocked = status === \"blocked\";\n  const [history, setHistory] = React.useState<number[]>(() => Array(BARS).fill(0));\n  const [now, setNow] = React.useState(startedAt ?? 0);\n  const [lastTranscript, setLastTranscript] = React.useState(transcript);\n  if (transcript && transcript !== lastTranscript) setLastTranscript(transcript);\n\n  // Keep a short history of levels so the waveform scrolls.\n  React.useEffect(() => {\n    if (!listening || level === undefined) return;\n    setHistory((h) => [...h.slice(1), Math.min(1, Math.max(0, level))]);\n  }, [level, listening]);\n\n  // The timer re-renders on each whole second, not every frame.\n  React.useEffect(() => {\n    if (!listening) {\n      setHistory(Array(BARS).fill(0));\n      return;\n    }\n    if (startedAt === undefined) return;\n    let timer = 0;\n    const tick = () => {\n      const t = Date.now();\n      setNow(t);\n      timer = window.setTimeout(tick, 1000 - ((t - startedAt) % 1000) + 5);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, [listening, startedAt]);\n\n  const seconds = startedAt === undefined ? 0 : Math.max(0, Math.floor((now - startedAt) / 1000));\n  const icon = status === \"idle\" ? \"mic\" : listening ? \"stop\" : transcribing ? \"spin\" : \"off\";\n  const press = status === \"idle\" ? onStart : listening ? onStop : undefined;\n  const showTranscript = listening && Boolean(transcript);\n\n  return (\n    <div className={`flex flex-col ${className}`} {...props}>\n      {/* The words so far open above the bar, and fold away with it. */}\n      <div\n        aria-hidden={!showTranscript || undefined}\n        className=\"grid\"\n        style={{\n          gridTemplateRows: showTranscript ? \"1fr\" : \"0fr\",\n          opacity: showTranscript ? 1 : 0,\n          transition: reduced ? \"none\" : `grid-template-rows 380ms ${MORPH}, opacity ${showTranscript ? \"300ms\" : \"160ms\"} ${MORPH}`,\n        }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <p aria-live=\"polite\" className=\"px-1 pb-2 text-[13px] leading-relaxed text-foreground\">\n            {transcript ?? lastTranscript}\n          </p>\n        </div>\n      </div>\n\n      <div role={alert ? \"alert\" : undefined} className={`flex items-center ${end ? \"flex-row-reverse\" : \"\"}`}>\n        {/* The one surface: a mic button that stretches into the listening bar and shrinks back. */}\n        <div\n          className={`flex shrink-0 items-center overflow-hidden rounded-full border border-border bg-background ${end ? \"flex-row-reverse\" : \"\"}`}\n          style={{\n            width: wide ? \"100%\" : 36,\n            height: wide ? 44 : 36,\n            // The button side keeps the tighter inset, whichever side that is.\n            [end ? \"paddingRight\" : \"paddingLeft\"]: wide ? 5 : 1,\n            [end ? \"paddingLeft\" : \"paddingRight\"]: wide ? 8 : 1,\n            transition: reduced ? \"none\" : `width 520ms ${MORPH}, height 520ms ${MORPH}, padding 520ms ${MORPH}`,\n          }}\n        >\n          <button\n            type=\"button\"\n            aria-label={status === \"idle\" ? \"Dictate\" : listening ? \"Stop and transcribe\" : transcribing ? \"Transcribing\" : blocked ? \"Microphone blocked\" : \"Didn’t catch that\"}\n            aria-disabled={!press || undefined}\n            onClick={() => press?.()}\n            className={`relative flex size-8 shrink-0 items-center justify-center rounded-full transition-[background-color,color,transform] duration-300 ${FOCUS} ${\n              listening\n                ? \"bg-primary text-primary-foreground active:scale-95\"\n                : alert\n                  ? \"cursor-default bg-muted text-muted-foreground\"\n                  : transcribing\n                    ? \"cursor-default text-muted-foreground\"\n                    : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n            }`}\n          >\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(icon === \"mic\", reduced)}>\n              <Mic className=\"size-4\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(icon === \"stop\", reduced)}>\n              <Square className=\"size-3\" fill=\"currentColor\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(icon === \"spin\", reduced)}>\n              <span className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${transcribing ? \"animate-spin\" : \"\"}`} />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(icon === \"off\", reduced)}>\n              <MicOff className=\"size-4\" />\n            </span>\n          </button>\n\n          {/* The waveform while listening; it settles flat as \"Transcribing\" rises over it. */}\n          <span className=\"relative mx-2 flex h-6 min-w-0 flex-1 items-center justify-center\" style={appear(wide, reduced)}>\n            <span aria-hidden=\"true\" className=\"flex h-full items-center justify-center gap-[3px] overflow-hidden\" style={appear(listening, reduced)}>\n              {history.map((v, i) =>\n                level === undefined ? (\n                  <span\n                    key={i}\n                    className={`h-full w-[3px] shrink-0 origin-center rounded-full bg-foreground/70 motion-reduce:animate-none ${listening ? \"animate-[ui-wave_1.1s_ease-in-out_infinite]\" : \"\"}`}\n                    style={{ animationDelay: `${(i % 7) * 90}ms`, transform: \"scaleY(0.35)\" }}\n                  />\n                ) : (\n                  <span\n                    key={i}\n                    className=\"w-[3px] shrink-0 rounded-full bg-foreground/70 transition-[height] duration-100 motion-reduce:transition-none\"\n                    style={{ height: `${listening ? Math.max(12, v * 100) : 12}%` }}\n                  />\n                )\n              )}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className={`absolute inset-y-0 flex items-center text-[13px] font-medium ${end ? \"right-1\" : \"left-1\"} ${transcribing ? SHEEN : \"\"}`}\n              style={appear(transcribing, reduced)}\n            >\n              <TextMorph>{transcribing ? \"Transcribing\" : \"Listening\"}</TextMorph>\n            </span>\n          </span>\n\n          {startedAt !== undefined && (\n            <span aria-hidden=\"true\" className=\"flex shrink-0 items-baseline font-mono text-[11.5px] tabular-nums text-muted-foreground\" style={appear(listening, reduced)}>\n              <NumberRoll value={Math.floor(seconds / 60)} duration={500} />:\n              <NumberRoll value={seconds % 60} format={TWO_DIGITS} direction=\"up\" duration={500} />\n            </span>\n          )}\n          {onCancel && (\n            <button\n              type=\"button\"\n              aria-label=\"Cancel\"\n              inert={!listening}\n              onClick={onCancel}\n              className={`${end ? \"mr-1\" : \"ml-1\"} flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n              style={appear(listening, reduced)}\n            >\n              <X className=\"size-3.5\" />\n            </button>\n          )}\n        </div>\n\n        {/* Blocked or not heard: the reason and the way back open beside the same circle. */}\n        <div\n          aria-hidden={!alert || undefined}\n          className=\"grid min-w-0 flex-1\"\n          style={{\n            // Folds by height, so while hidden it takes no room (and its text can't stack up in a sliver of width).\n            gridTemplateRows: alert ? \"1fr\" : \"0fr\",\n            ...appear(alert, reduced),\n            transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, ${alert ? `opacity 320ms ${MORPH} 120ms, filter 320ms ${MORPH} 120ms` : \"opacity 140ms ease-out, filter 140ms ease-out\"}`,\n          }}\n        >\n          <div className={`flex min-h-0 min-w-0 items-center gap-3 overflow-hidden ${end ? \"pr-3\" : \"pl-3\"}`}>\n            <span className=\"min-w-0 flex-1 text-[12.5px] leading-5\">\n              <span className=\"block font-medium text-foreground\">\n                <TextMorph animateWidth={false}>{blocked ? \"Microphone blocked\" : \"Didn’t catch that\"}</TextMorph>\n              </span>\n              <span className=\"block text-muted-foreground\">\n                {errorText ?? (blocked ? \"Allow the microphone in your browser’s site settings, then try again.\" : \"Speak a little closer to the microphone.\")}\n              </span>\n            </span>\n            {onStart && (\n              <button\n                type=\"button\"\n                inert={!alert}\n                onClick={onStart}\n                className={`flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-[12px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n              >\n                <RotateCcw aria-hidden=\"true\" className=\"size-3\" />\n                Try again\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n      <span className=\"sr-only\" role=\"status\">\n        {listening ? \"Listening\" : transcribing ? \"Transcribing\" : \"\"}\n      </span>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-wave":{"0%, 100%":{"transform":"scaleY(0.35)"},"50%":{"transform":"scaleY(1)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/voice-input"}