{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-input","type":"registry:ui","title":"VoiceInput","description":"A mic button that opens into a recording panel: a live waveform painted from amplitude levels you supply, a stopwatch, the transcript as it arrives, and a cross and a check to throw it away or keep it","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/voice-input/VoiceInput.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useInsertionEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useElapsed } from \"../../internals/use-elapsed\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { drawWaveformFrame, fakeWaveSample } from \"../../internals/waveform-core\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./voice-input.css\";\n\n/**\n * Props for VoiceInput\n */\nexport interface VoiceInputProps {\n\t/**\n\t * Whether a recording is in progress. Controlled when supplied — pair it\n\t * with `onActiveChange`, the React counterpart of the Svelte source's\n\t * `bind:active`; the component keeps its own copy either way, so the mic,\n\t * cancel and confirm buttons all write through it, and setting it from\n\t * outside opens or closes the panel without firing any callback.\n\t */\n\tactive?: boolean;\n\t/**\n\t * Called with the new recording state whenever one of the buttons changes\n\t * it. Never fired for a change driven from outside through `active` — the\n\t * callbacks report what the user did, not what the state is.\n\t */\n\tonActiveChange?: (active: boolean) => void;\n\t/**\n\t * Live text pushed by the consumer as their recogniser produces it. Rendered\n\t * muted under the waveform; a growing string simply grows in place.\n\t */\n\ttranscript?: string;\n\t/**\n\t * Amplitude levels in 0..1, bridged from whatever audio pipeline the\n\t * consumer already owns. This component never opens a microphone itself.\n\t */\n\tsamples?: ArrayLike<number>;\n\t/**\n\t * Draw a synthetic waveform when no `samples` arrive, so the component is\n\t * legible on a docs page or in a story with no audio behind it.\n\t */\n\tdemo?: boolean;\n\t/** Called when the mic button starts a recording. */\n\tonStart?: () => void;\n\t/** Called when the confirm button ends a recording and keeps the transcript. */\n\tonStop?: () => void;\n\t/** Called when the cancel button abandons a recording. */\n\tonCancel?: () => void;\n\t/** Waveform height in CSS pixels. Clamped to 16..240. */\n\theight?: number;\n\t/** Any CSS colour for the bars, including a `var()` — resolved before painting. */\n\tcolor?: string;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/** Bar geometry, in CSS pixels. Fixed: the waveform is a meter, not a chart. */\nconst BAR_WIDTH = 3;\nconst BAR_GAP = 2;\n/** Amplitude floor, so silence still reads as a live signal rather than a gap. */\nconst MIN_AMP = 0.06;\n/** Below the floor the bars stop resolving; above the ceiling this is a chart. */\nconst MIN_HEIGHT = 16;\nconst MAX_HEIGHT = 240;\n/** Handed to the renderer when there is no signal and no stand-in for one. */\nconst NO_SIGNAL: number[] = [];\n\n/**\n * A mic button that opens into a recording panel: a live waveform, a stopwatch,\n * the transcript as it arrives, and a cross and a check to throw it away or keep\n * it.\n *\n * The root element arrives through the ref channel rather than a `ref` prop, per\n * PORTING.md — the Svelte source declares `ref = $bindable(null)`.\n */\nexport const VoiceInput = forwardRef<HTMLDivElement, VoiceInputProps>(function VoiceInput(\n\t{\n\t\tactive: activeProp,\n\t\tonActiveChange,\n\t\ttranscript = \"\",\n\t\tsamples,\n\t\tdemo = false,\n\t\tonStart,\n\t\tonStop,\n\t\tonCancel,\n\t\theight = 48,\n\t\tcolor = \"var(--ft-voice-color, currentColor)\",\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tref\n) {\n\t/*\n\t * `active = $bindable(false)` on the Svelte side: a supplied prop opens or\n\t * closes the panel, and the component's own copy is what the three buttons\n\t * write. One shape covers a caller driving `active` from its own state, a\n\t * caller who passes only `onActiveChange`, and a caller who passes neither\n\t * and lets the buttons run the whole thing.\n\t *\n\t * Re-synced in the render path, not an effect: an effect would paint one\n\t * frame of the stale value first, and the pattern React documents for\n\t * \"adjust state when a prop changes\" is exactly this — set state on the\n\t * component that owns it, during render, and let React restart the render\n\t * before committing anything.\n\t */\n\tconst [active, setActiveState] = useState(activeProp ?? false);\n\tconst [lastActiveProp, setLastActiveProp] = useState(activeProp);\n\tif (lastActiveProp !== activeProp) {\n\t\tsetLastActiveProp(activeProp);\n\t\tsetActiveState(activeProp ?? false);\n\t}\n\n\t/*\n\t * The canvas node itself, not a `useRef`: it exists only inside the `active`\n\t * branch, so an effect keyed on a ref would fire while `current` is still\n\t * null and never run again (convention C-1).\n\t */\n\tconst [canvasEl, canvasRef] = useElementRef<HTMLCanvasElement>();\n\n\tconst barHeight = Number.isFinite(height)\n\t\t? Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, height))\n\t\t: 48;\n\n\tconst elapsed = useElapsed();\n\t// ISO 8601 duration, so the reading is machine-readable and not just decorative.\n\tconst elapsedDateTime = `PT${Math.max(0, Math.floor(elapsed.ms / 1000))}S`;\n\n\t// `start` hands back its own stop function, which doubles as the cleanup: the\n\t// stopwatch exists only between the two, and each recording is timed from its\n\t// own zero rather than continuing the last one.\n\tconst { start: startElapsed } = elapsed;\n\tuseEffect(() => {\n\t\tif (active) return startElapsed(Date.now());\n\t}, [active, startElapsed]);\n\n\t/*\n\t * Read from inside the running loop only, never as effect dependencies — the\n\t * counterpart of the source reading them inside the rAF callback, which runs\n\t * outside any tracking context.\n\t */\n\tconst samplesRef = useLiveRef(samples);\n\tconst demoRef = useLiveRef(demo);\n\n\tconst playCue = useSoundCue(sound);\n\n\tconst focusOnSwap = useRef<\"panel\" | \"mic\" | null>(null);\n\tconst micButtonRef = useRef<HTMLButtonElement | null>(null);\n\tconst cancelButtonRef = useRef<HTMLButtonElement | null>(null);\n\n\t/*\n\t * The synchronous half of `active`, and the counterpart of the source's plain\n\t * `active = false`. State is what renders; the guards below need what the\n\t * click before them wrote, and two dispatches inside one batch both close\n\t * over the same render's state. Written by `setActive` and re-synced from\n\t * every commit, so an `active` driven from outside lands here too.\n\t */\n\tconst activeRef = useRef(active);\n\tuseInsertionEffect(() => {\n\t\tactiveRef.current = active;\n\t}, [active]);\n\n\t// The only place `active` changes on this side of the wire.\n\tfunction setActive(next: boolean) {\n\t\tif (activeRef.current === next) return;\n\t\tactiveRef.current = next;\n\t\tsetActiveState(next);\n\t\tonActiveChange?.(next);\n\t}\n\n\t/*\n\t * Each of the three handlers re-reads the recording state first, exactly as\n\t * the source does. Without the guard a second click dispatched before the\n\t * state has flushed runs the whole body again, and the consumer's callback —\n\t * and the cue — fire twice for one gesture.\n\t */\n\tfunction handleStart() {\n\t\tif (activeRef.current) return;\n\t\tsetActive(true);\n\t\tplayCue(\"open\");\n\t\tfocusOnSwap.current = \"panel\";\n\t\tonStart?.();\n\t}\n\n\tfunction handleCancel() {\n\t\tif (!activeRef.current) return;\n\t\tplayCue(\"close\");\n\t\tsetActive(false);\n\t\tfocusOnSwap.current = \"mic\";\n\t\tonCancel?.();\n\t}\n\n\tfunction handleFinish() {\n\t\tif (!activeRef.current) return;\n\t\t// This is a commit gesture, not an outcome: the component never opens a\n\t\t// microphone or runs a recogniser (see the `samples`/`transcript` docs\n\t\t// above), so it has nothing of its own to resolve. `select` matches every\n\t\t// other commit branch in the library; the consumer's own pipeline is what\n\t\t// actually succeeds or fails, and can play `success`/`error` itself once\n\t\t// it knows which.\n\t\tplayCue(\"select\");\n\t\tsetActive(false);\n\t\tfocusOnSwap.current = \"mic\";\n\t\tonStop?.();\n\t}\n\n\t/*\n\t * The button the user just pressed is unmounted by the state change it\n\t * caused, so without a move focus lands on <body> and the next Tab restarts\n\t * from the top of the page. Only a user-driven flip moves it: an `active`\n\t * driven from outside is the consumer's own gesture, and stealing focus for\n\t * it would be a surprise. A layout effect, so the move is in place for the\n\t * frame the new control is first painted in, and idempotent — the intent is\n\t * consumed on the first run, so a second invocation focuses nothing.\n\t */\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst target = focusOnSwap.current;\n\t\tif (!target) return;\n\t\tfocusOnSwap.current = null;\n\t\tconst el = target === \"panel\" ? cancelButtonRef.current : micButtonRef.current;\n\t\tel?.focus();\n\t}, [active]);\n\n\t/*\n\t * The whole canvas lifetime is one effect. It re-runs when the panel appears\n\t * or disappears, when the canvas is (re)attached, and when the geometry or the\n\t * colour changes — all rare. Everything that changes per frame is read from\n\t * inside the rAF callback through a live ref, so a consumer pushing sixty\n\t * sample buffers a second does not tear the loop down and rebuild it sixty\n\t * times a second.\n\t */\n\tuseEffect(() => {\n\t\t// An effect never runs on the server, and `canvasEl` is null until the\n\t\t// panel's canvas is in the document — together they are the source's\n\t\t// `mounted` flag.\n\t\tif (!active) return;\n\n\t\tconst el = canvasEl;\n\t\tif (!el) return;\n\n\t\t// jsdom and a browser that refuses to back the surface both hand back null.\n\t\t// The panel is still legible without bars — a timer, a transcript and two\n\t\t// buttons — so this is a return, not a throw.\n\t\tconst context = el.getContext(\"2d\");\n\t\tif (!context) return;\n\n\t\t// Re-declared with non-null types: the helpers below capture both, and a\n\t\t// narrowing does not survive the closure boundary.\n\t\tconst canvas: HTMLCanvasElement = el;\n\t\tconst ctx: CanvasRenderingContext2D = context;\n\n\t\t// Dependencies on purpose: a new height or colour restarts the loop with it.\n\t\tconst h = barHeight;\n\t\tconst requested = color;\n\n\t\t// `fillStyle` understands neither `var()` nor `currentColor`, so the prop is\n\t\t// written onto the element as its CSS `color` (see the `style` below) and\n\t\t// read back resolved. One style recalculation per effect run, never one per\n\t\t// frame.\n\t\tconst fill = getComputedStyle(canvas).color || requested;\n\n\t\tconst style = {\n\t\t\tcolor: fill,\n\t\t\tbarWidth: BAR_WIDTH,\n\t\t\tgap: BAR_GAP,\n\t\t\tminAmp: MIN_AMP,\n\t\t\tmirror: true,\n\t\t};\n\n\t\tconst reduced = prefersReducedMotion();\n\n\t\tlet cssWidth = 0;\n\t\tlet synthetic = new Float32Array(0);\n\t\tlet frame: number | undefined;\n\n\t\tfunction resize() {\n\t\t\tconst dpr = window.devicePixelRatio || 1;\n\t\t\tcssWidth = canvas.clientWidth;\n\t\t\tcanvas.width = Math.max(1, Math.round(cssWidth * dpr));\n\t\t\tcanvas.height = Math.max(1, Math.round(h * dpr));\n\t\t\t// Assigning width or height resets the context, so the device-pixel\n\t\t\t// transform is re-applied here and every measurement below stays in CSS\n\t\t\t// pixels.\n\t\t\tctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n\t\t\t// One synthetic sample per bar, so `demo` never has to be resampled.\n\t\t\tconst bars = Math.max(0, Math.floor(cssWidth / (BAR_WIDTH + BAR_GAP)));\n\t\t\tif (synthetic.length !== bars) synthetic = new Float32Array(bars);\n\t\t}\n\n\t\tfunction levels(tMs: number): ArrayLike<number> {\n\t\t\t// A real pipeline always wins. `demo` is the stand-in for a page with no\n\t\t\t// microphone behind it, and neither one means an honest flat floor.\n\t\t\tconst pushed = samplesRef.current;\n\t\t\tif (pushed && pushed.length > 0) return pushed;\n\t\t\tif (!demoRef.current) return NO_SIGNAL;\n\t\t\tfor (let i = 0; i < synthetic.length; i++) synthetic[i] = fakeWaveSample(i, tMs);\n\t\t\treturn synthetic;\n\t\t}\n\n\t\tfunction paint(tMs: number) {\n\t\t\tdrawWaveformFrame(ctx, levels(tMs), cssWidth, h, style);\n\t\t}\n\n\t\tresize();\n\n\t\tconst observer = new ResizeObserver(() => {\n\t\t\tresize();\n\t\t\t// The animated path repaints on its own a frame later; the still one has\n\t\t\t// to be told, or a resized panel keeps a stale drawing.\n\t\t\tif (reduced) paint(0);\n\t\t});\n\t\tobserver.observe(canvas);\n\n\t\tif (reduced) {\n\t\t\t// One frame and then nothing. A sixty-per-second amplitude meter is\n\t\t\t// precisely the motion this setting asks us not to run, and the recording\n\t\t\t// is still announced, still timed, and still transcribed without it.\n\t\t\t// Incoming samples cannot restart the effect and turn the still frame back\n\t\t\t// into an animation: they are read through a ref, never a dependency.\n\t\t\tpaint(0);\n\t\t} else {\n\t\t\tconst started = performance.now();\n\t\t\tconst loop = (now: number) => {\n\t\t\t\tpaint(now - started);\n\t\t\t\tframe = requestAnimationFrame(loop);\n\t\t\t};\n\t\t\tframe = requestAnimationFrame(loop);\n\t\t}\n\n\t\treturn () => {\n\t\t\tif (frame !== undefined) cancelAnimationFrame(frame);\n\t\t\tobserver.disconnect();\n\t\t};\n\t}, [active, canvasEl, barHeight, color, samplesRef, demoRef]);\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName={cn(\"ft-voice\", className)}\n\t\t\trole=\"group\"\n\t\t\taria-label=\"Voice input\"\n\t\t\tdata-active={active ? \"true\" : \"false\"}\n\t\t>\n\t\t\t{/*\n\t\t\t\tA live region that outlives the state change it reports: swapping the\n\t\t\t\ttext inside a region already in the document is announced reliably,\n\t\t\t\twhere a region inserted along with its own text is not.\n\t\t\t*/}\n\t\t\t<span className=\"ft-voice-status sr-only\" role=\"status\">\n\t\t\t\t{active ? \"Recording\" : \"\"}\n\t\t\t</span>\n\n\t\t\t{!active ? (\n\t\t\t\t<button\n\t\t\t\t\tref={micButtonRef}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-voice-mic text-muted-foreground hover:text-foreground hover:bg-foreground/5 inline-flex size-9 flex-none items-center justify-center rounded-full border transition-colors\"\n\t\t\t\t\taria-label=\"Start voice input\"\n\t\t\t\t\tonClick={handleStart}\n\t\t\t\t>\n\t\t\t\t\t<svg\n\t\t\t\t\t\tclassName=\"size-4\"\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z\" />\n\t\t\t\t\t\t<path d=\"M19 10v2a7 7 0 0 1-14 0v-2\" />\n\t\t\t\t\t\t<path d=\"M12 19v3\" />\n\t\t\t\t\t</svg>\n\t\t\t\t</button>\n\t\t\t) : (\n\t\t\t\t<div className=\"ft-voice-panel bg-card/50 flex w-full items-center gap-3 rounded-lg border px-3 py-2\">\n\t\t\t\t\t<span className=\"ft-voice-dot flex-none\" aria-hidden=\"true\" />\n\n\t\t\t\t\t<div className=\"ft-voice-signal min-w-0 flex-1\">\n\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\tPurely a picture of the amplitude that the timer and the\n\t\t\t\t\t\t\ttranscript already state in words, so it is hidden rather than\n\t\t\t\t\t\t\tdescribed.\n\t\t\t\t\t\t*/}\n\t\t\t\t\t\t<canvas\n\t\t\t\t\t\t\tref={canvasRef}\n\t\t\t\t\t\t\tclassName=\"ft-voice-canvas block w-full\"\n\t\t\t\t\t\t\tstyle={{ height: `${barHeight}px`, color }}\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t{transcript ? (\n\t\t\t\t\t\t\t<p className=\"ft-voice-transcript text-muted-foreground mt-1 text-xs leading-relaxed\">\n\t\t\t\t\t\t\t\t{transcript}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<time\n\t\t\t\t\t\tclassName=\"ft-voice-elapsed text-muted-foreground flex-none text-xs tabular-nums\"\n\t\t\t\t\t\tdateTime={elapsedDateTime}\n\t\t\t\t\t>\n\t\t\t\t\t\t{elapsed.text}\n\t\t\t\t\t</time>\n\n\t\t\t\t\t<button\n\t\t\t\t\t\tref={cancelButtonRef}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"ft-voice-btn text-muted-foreground hover:text-foreground hover:bg-foreground/5 inline-flex size-7 flex-none items-center justify-center rounded-full transition-colors\"\n\t\t\t\t\t\taria-label=\"Cancel voice input\"\n\t\t\t\t\t\tonClick={handleCancel}\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-3.5\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path d=\"M18 6 6 18\" />\n\t\t\t\t\t\t\t<path d=\"m6 6 12 12\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"ft-voice-btn ft-voice-confirm text-foreground hover:bg-foreground/10 bg-foreground/5 inline-flex size-7 flex-none items-center justify-center rounded-full transition-colors\"\n\t\t\t\t\t\taria-label=\"Stop voice input\"\n\t\t\t\t\t\tonClick={handleFinish}\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-3.5\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"2.5\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path d=\"M20 6 9 17l-5-5\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/voice-input/VoiceInput.tsx"},{"path":"react/src/components/voice-input/index.ts","content":"\"use client\";\n\nexport { VoiceInput } from \"./VoiceInput\";\nexport type { VoiceInputProps } from \"./VoiceInput\";\n","type":"registry:ui","target":"@ui/fancy/components/voice-input/index.ts"},{"path":"react/src/components/voice-input/voice-input.css","content":"/*\n * Svelte scoped these rules to the component; plain CSS is global, so every one\n * of them is anchored on the root class `.ft-voice` the source already renders.\n */\n\n/*\n * The dot is read at the point of use through two hooks: this component's own\n * `--ft-voice-dot`, then the `--ft-status-*` vocabulary the whole AI family\n * shares. Setting either anywhere up the tree retints it without having to win\n * a specificity fight against these rules.\n *\n * It takes `running` rather than `error` because a recording in progress is a\n * run in flight, not a failure — retinting the family's error colour should\n * not turn the record light red. Consumers who want the classic red record dot\n * say so in one line: `--ft-voice-dot: oklch(0.6 0.2 25)`.\n */\n.ft-voice .ft-voice-dot {\n\tposition: relative;\n\tdisplay: inline-block;\n\twidth: 0.5rem;\n\theight: 0.5rem;\n\tborder-radius: 9999px;\n\tbackground: var(\n\t\t--ft-voice-dot,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-voice .ft-voice-dot::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: inherit;\n\tbackground: inherit;\n\topacity: 0;\n}\n\n.ft-voice .ft-voice-transcript {\n\t/* Wrapping rather than scrolling sideways, and capped rather than unbounded:\n\t   a two-minute dictation should not push the controls off the panel. */\n\tmax-height: var(--ft-voice-transcript-max-height, 4.5rem);\n\toverflow-y: auto;\n\toverflow-wrap: anywhere;\n}\n\n/*\n * The only animation in the component lives behind the query, so reduced\n * motion is not a second variant to keep in sync: the dot is simply a static\n * dot, still the same colour, still saying the same thing.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-voice .ft-voice-dot::after {\n\t\tanimation: ft-voice-pulse var(--ft-voice-pulse-duration, 1.6s) cubic-bezier(0, 0, 0.2, 1)\n\t\t\tinfinite;\n\t}\n}\n\n@keyframes ft-voice-pulse {\n\t0% {\n\t\ttransform: scale(1);\n\t\topacity: 0.55;\n\t}\n\t75%,\n\t100% {\n\t\ttransform: scale(2.6);\n\t\topacity: 0;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/voice-input/voice-input.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/voice-input?framework=react"}}