Voice input
AIProDictation for AI features: a mic button with a listening state, live level bars, a timer, a stop button and a transcript preview with interim words.
npx prodular-ui add voice-inputPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Dictate visit notes
Press the mic, watch the levels and the transcript fill in, then stop to transcribe. Levels are simulated in this demo; use useAudioLevels with a real microphone stream.
Visit notes
WO-3318 · Harbor Point, unit 4
Press the mic and describe the visit.
"use client"
import * as React from "react"
import { VoiceInput, VoiceInputButton, VoiceInputStop, VoiceInputTimer, VoiceLevels, VoiceTranscript, type VoiceStatus,} from "@/components/ui/voice-input"
const words = "Log a visit for Harbor Point unit 4. Replaced the condenser fan motor, tested for twenty minutes, no leaks. Customer wants a follow up on the thermostat next week.".split( " " )
const BARS = 28
export default function VoiceInputDictation() { const [status, setStatus] = React.useState<VoiceStatus>("idle") const [levels, setLevels] = React.useState<number[]>(() => new Array(BARS).fill(0)) const [seconds, setSeconds] = React.useState(0) const [count, setCount] = React.useState(0) const [final, setFinal] = React.useState("")
React.useEffect(() => { if (status !== "listening") return let t = 0 const level = setInterval(() => { t += 1 // Speech like envelope: syllable bumps on top of a slow breath. const speech = Math.abs(Math.sin(t * 0.9)) * 0.55 + Math.random() * 0.4 const pause = Math.sin(t * 0.08) > 0.85 ? 0.1 : 1 setLevels((prev) => [...prev.slice(1), Math.min(speech * pause, 1)]) }, 70) const clock = setInterval(() => setSeconds((s) => s + 1), 1000) const speech = setInterval(() => setCount((c) => Math.min(c + 1, words.length)), 260) return () => { clearInterval(level) clearInterval(clock) clearInterval(speech) } }, [status])
const start = () => { setSeconds(0) setCount(0) setFinal("") setStatus("listening") }
const stop = () => { setStatus("processing") setLevels(new Array(BARS).fill(0)) setTimeout(() => { setFinal(words.slice(0, Math.max(count, 8)).join(" ")) setStatus("idle") }, 900) }
// The last few words stay interim until the recognizer settles on them. const heard = words.slice(0, count) const settled = heard.slice(0, Math.max(heard.length - 3, 0)).join(" ") const interim = heard.slice(Math.max(heard.length - 3, 0)).join(" ")
return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl border bg-card p-4 shadow-xs"> <div className="flex items-center justify-between gap-3"> <div> <h3 className="text-sm font-medium">Visit notes</h3> <p className="text-[13px] text-muted-foreground">WO-3318 · Harbor Point, unit 4</p> </div> <VoiceInput status={status}> {status === "idle" ? ( <VoiceInputButton onClick={start} /> ) : ( <> <VoiceInputButton aria-label="Listening" /> <VoiceLevels levels={levels} /> <VoiceInputTimer seconds={seconds} /> <VoiceInputStop onClick={stop} /> </> )} </VoiceInput> </div> <div className="min-h-24 border-t pt-3"> {status === "idle" && !final ? ( <p className="text-sm text-muted-foreground">Press the mic and describe the visit.</p> ) : status === "idle" ? ( <p className="text-sm leading-relaxed">{final}</p> ) : ( <VoiceTranscript interim={status === "listening" ? interim : undefined}> {status === "processing" ? heard.join(" ") : settled} </VoiceTranscript> )} </div> </div> )}States
Idle, listening and processing.
"use client"
import * as React from "react"
import { VoiceInput, VoiceInputButton, VoiceInputStop, VoiceInputTimer, VoiceLevels,} from "@/components/ui/voice-input"
function useMovingLevels(bars: number) { const [levels, setLevels] = React.useState<number[]>(() => Array.from({ length: bars }, (_, i) => 0.2 + 0.6 * Math.abs(Math.sin(i * 0.7))) ) React.useEffect(() => { let t = 0 const id = setInterval(() => { t += 1 setLevels((prev) => [...prev.slice(1), Math.min(Math.abs(Math.sin(t * 0.6)) * 0.6 + Math.random() * 0.4, 1)]) }, 80) return () => clearInterval(id) }, []) return levels}
export default function VoiceInputStates() { const levels = useMovingLevels(20) return ( <div className="flex flex-col items-center gap-6"> <div className="flex items-center gap-3"> <span className="w-20 text-right text-[13px] text-muted-foreground">Idle</span> <VoiceInput status="idle"> <VoiceInputButton /> </VoiceInput> </div> <div className="flex items-center gap-3"> <span className="w-20 text-right text-[13px] text-muted-foreground">Listening</span> <VoiceInput status="listening"> <VoiceInputButton /> <VoiceLevels levels={levels} /> <VoiceInputTimer seconds={14} /> <VoiceInputStop /> </VoiceInput> </div> <div className="flex items-center gap-3"> <span className="w-20 text-right text-[13px] text-muted-foreground">Processing</span> <VoiceInput status="processing"> <VoiceInputButton /> <VoiceLevels levels={new Array(20).fill(0)} /> <VoiceInputTimer seconds={21} /> <VoiceInputStop /> </VoiceInput> </div> </div> )}Anatomy
<VoiceInput status={status}> <VoiceInputButton onClick={start} /> <VoiceLevels levels={levels} /> <VoiceInputTimer seconds={seconds} /> <VoiceInputStop onClick={stop} /></VoiceInput><VoiceTranscript interim={interim}>{finalText}</VoiceTranscript>Guidance
Use it when
- Use it where people describe something faster than they type it: visit notes, ticket replies, prompts on mobile.
- Show interim words muted and settled words in full color so people see the recognizer working.
- Let people edit the transcript before it is sent or saved.
Avoid
- Starting to listen without a press. The microphone only opens on an explicit action.
- Sending the transcript automatically when listening stops.
- Hiding the timer. People need to know the mic is still on.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Only request microphone access with getUserMedia inside the click handler of VoiceInputButton, and stop every track of the stream when the person presses stop or leaves the view.
- Pass status on every render: listening while the mic is open, processing while the final transcript is computed, idle otherwise.
- Drive VoiceLevels from useAudioLevels(stream) with a real stream. Do not show fake levels in production.
- Always render VoiceInputStop and VoiceInputTimer while listening. Pass interim to VoiceTranscript for words the recognizer may still change.
- Put the transcript in an editable field after processing. Never submit it without the person confirming.
API
VoiceInput
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "listening" | "processing" | "idle" | Exposed as data-status. Shapes the container and the parts. |
VoiceInputButton
| Prop | Type | Default | Description |
|---|---|---|---|
| Button props | ComponentProps<typeof Button> | Starts listening. Pressed and pulsing while listening. |
VoiceLevels
| Prop | Type | Default | Description |
|---|---|---|---|
| levels | number[] | One value from 0 to 1 per bar, newest last. |
VoiceInputTimer
| Prop | Type | Default | Description |
|---|---|---|---|
| seconds | number | Elapsed listening time. |
VoiceInputStop
| Prop | Type | Default | Description |
|---|---|---|---|
| Button props | ComponentProps<typeof Button> | Stops and transcribes. Shows a spinner while processing. |
VoiceTranscript
| Prop | Type | Default | Description |
|---|---|---|---|
| interim | string | Words that may still change, shown muted after children. |
useAudioLevels
| Prop | Type | Default | Description |
|---|---|---|---|
| (stream, bars) | (MediaStream | null, number) => number[] | Live levels from a microphone stream. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add voice-input# or, with the registry in components.jsonnpx shadcn@latest add @prodular/voice-input