A microphone control as a round button or an expanding recorder with a scrolling waveform, timer, discard and send, plus a hook that reads the real microphone level.
Press the button and speak.
"use client"
import * as React from "react"
import { VoiceInput, type VoiceState } from "@/components/ballmac/voice-input"
/** A speech-like level. In an app use useMicrophoneLevel(state === "listening"). */
function useFakeLevel(active: boolean) {
const [level, setLevel] = React.useState(0)
React.useEffect(() => {
if (!active) {
setLevel(0)
return
}
let t = 0
const id = setInterval(() => {
t += 0.45
const burst = Math.max(0, Math.sin(t * 0.5))
setLevel(Math.min(1, burst * (0.5 + 0.5 * Math.abs(Math.sin(t * 2.3)))))
}, 70)
return () => clearInterval(id)
}, [active])
return level
}
export default function VoiceInputDemo() {
const [state, setState] = React.useState<VoiceState>("idle")
const [result, setResult] = React.useState<string | null>(null)
const level = useFakeLevel(state === "listening")
React.useEffect(() => {
if (state !== "processing") return
const id = setTimeout(() => {
setResult("Move the standup to ten and invite Priya.")
setState("idle")
}, 1400)
return () => clearTimeout(id)
}, [state])
return (
<div className="grid w-full max-w-md gap-3">
<VoiceInput
variant="bar"
state={state}
level={level}
label="Dictate a message"
onStart={() => {
setResult(null)
setState("listening")
}}
onStop={() => setState("processing")}
onCancel={() => setState("idle")}
/>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{result ? <>Heard: <span className="text-foreground">“{result}”</span></> : "Press the button and speak."}
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/voice-inputInstall the dependencies.
$ pnpm add motion@^12 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/voice-input.tsx// Ballmac UI: Voice Input. https://ui.ballmac.com/components/voice-input "use client" import * as React from "react" import { Check, Loader2, Mic, Square, X } from "lucide-react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type VoiceState = "idle" | "listening" | "processing" /** * Microphone loudness from 0 to 1 while `active` is true, read from the real input with the Web Audio API. * Nothing is requested until `active` turns on. If the user refuses, `error` holds the reason. */ function useMicrophoneLevel(active: boolean) { const [level, setLevel] = React.useState(0) const [error, setError] = React.useState<string | null>(null) React.useEffect(() => { if (!active) { setLevel(0) return } let cancelled = false let raf = 0 let stream: MediaStream | undefined let ctx: AudioContext | undefined async function start() { try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }) if (cancelled) return stream.getTracks().forEach((t) => t.stop()) ctx = new AudioContext() const analyser = ctx.createAnalyser() analyser.fftSize = 256 ctx.createMediaStreamSource(stream).connect(analyser) const data = new Uint8Array(analyser.frequencyBinCount) const tick = () => { analyser.getByteTimeDomainData(data) let sum = 0 for (const v of data) sum += ((v - 128) / 128) ** 2 setLevel(Math.min(1, Math.sqrt(sum / data.length) * 3.2)) raf = requestAnimationFrame(tick) } tick() } catch (e) { setError(e instanceof Error ? e.message : "Microphone unavailable") } } setError(null) void start() return () => { cancelled = true cancelAnimationFrame(raf) stream?.getTracks().forEach((t) => t.stop()) void ctx?.close() } }, [active]) return { level, error } } function formatClock(total: number) { const s = Math.max(0, Math.floor(total)) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` } type VoiceInputProps = Omit<React.ComponentProps<"div">, "children" | "onChange"> & { /** Current state (controlled). Without it the component keeps its own state and moves idle → listening → processing. */ state?: VoiceState /** Called when recording should begin. */ onStart?: () => void /** Called when the person finishes and wants the audio used. */ onStop?: () => void /** Called when the person throws the recording away. */ onCancel?: () => void /** Loudness from 0 to 1, for example from `useMicrophoneLevel`. Drives the waveform and the button's glow. */ level?: number /** "button" is a single round microphone button. "bar" expands into a recorder with waveform, timer, cancel and send. */ variant?: "button" | "bar" /** Seconds recorded. When omitted the component times itself. */ duration?: number /** Text in the bar while processing. */ processingLabel?: string /** Accessible name of the button when idle. */ label?: string } const BAR_COUNT = 32 function VoiceInput({ state: stateProp, onStart, onStop, onCancel, level = 0, variant = "button", duration, processingLabel, label, className, ...props }: VoiceInputProps) { const msg = useMessages() processingLabel ??= msg("voice-input.processingLabel", "Transcribing…") label ??= msg("voice-input.label", "Start voice input") const reduce = useReducedMotion() const [internal, setInternal] = React.useState<VoiceState>("idle") const state = stateProp ?? internal const [seconds, setSeconds] = React.useState(0) const [history, setHistory] = React.useState<number[]>(() => Array<number>(BAR_COUNT).fill(0)) const startRef = React.useRef<HTMLButtonElement>(null) const prev = React.useRef<VoiceState>(state) const listening = state === "listening" React.useEffect(() => { if (!listening) { setSeconds(0) setHistory(Array<number>(BAR_COUNT).fill(0)) return } const id = setInterval(() => setSeconds((s) => s + 1), 1000) return () => clearInterval(id) }, [listening]) const levelRef = React.useRef(level) React.useEffect(() => { levelRef.current = level }) // Sample the level on a steady beat so the waveform scrolls even when the level holds still. React.useEffect(() => { if (!listening || variant !== "bar") return const id = setInterval(() => { setHistory((h) => [...h.slice(1), Math.min(1, Math.max(0, levelRef.current))]) }, 70) return () => clearInterval(id) }, [listening, variant]) // After a recording ends, put focus back where the microphone button is. React.useEffect(() => { if (prev.current !== "idle" && state === "idle") startRef.current?.focus() prev.current = state }, [state]) function start() { setInternal("listening") onStart?.() } function stop() { setInternal("processing") onStop?.() } function cancel() { setInternal("idle") onCancel?.() } const shownSeconds = duration ?? seconds const announce = listening ? "Listening" : state === "processing" ? processingLabel : "" const escape = (e: React.KeyboardEvent) => { if (e.key === "Escape" && listening) { e.stopPropagation() cancel() } } if (variant === "button") { return ( <div data-slot="voice-input" data-state={state} data-variant="button" className={cn("relative inline-flex size-10 items-center justify-center", className)} onKeyDown={escape} {...props} > {listening && !reduce && ( <motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-full bg-foreground/15" animate={{ scale: 1 + level * 0.7, opacity: 0.4 + level * 0.6 }} transition={{ type: "spring", stiffness: 260, damping: 18 }} /> )} <button ref={startRef} type="button" disabled={state === "processing"} aria-label={listening ? msg("voice-input.stopAndUseRecording", "Stop and use recording") : state === "processing" ? processingLabel : label} aria-pressed={listening} onClick={listening ? stop : start} className={cn( "relative inline-flex size-10 items-center justify-center rounded-full border outline-none transition-[background-color,color,border-color,box-shadow] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-progress motion-reduce:transition-none", listening ? "border-transparent bg-primary text-primary-foreground" : "bg-background text-foreground shadow-xs hover:bg-accent" )} > {state === "processing" ? ( <Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" /> ) : listening ? ( <Square aria-hidden="true" className="size-3.5 fill-current" /> ) : ( <Mic aria-hidden="true" className="size-4" /> )} </button> <span className="sr-only" role="status" aria-live="polite"> {announce} </span> </div> ) } return ( <div data-slot="voice-input" data-state={state} data-variant="bar" className={cn("w-full max-w-md", className)} onKeyDown={escape} {...props} > {state === "idle" ? ( <button ref={startRef} type="button" onClick={start} className="inline-flex h-10 items-center gap-2 rounded-full border bg-background px-4 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50" > <Mic aria-hidden="true" className="size-4" /> {label} </button> ) : ( <div className="flex h-12 items-center gap-2 rounded-full border bg-card px-1.5 shadow-xs"> <button type="button" onClick={cancel} disabled={state === "processing"} aria-label={msg("voice-input.discardRecording", "Discard recording")} className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" > <X aria-hidden="true" className="size-4" /> </button> {listening ? ( <> <div aria-hidden="true" className="flex h-8 min-w-0 flex-1 items-center justify-between gap-[3px] overflow-hidden px-1"> {history.map((v, i) => ( <span key={i} className="w-[3px] shrink-0 rounded-full bg-foreground" style={{ height: `${Math.max(12, Math.round(12 + v * 88))}%`, opacity: 0.25 + (i / BAR_COUNT) * 0.75, }} /> ))} </div> <span className="w-10 shrink-0 text-end font-mono text-xs text-muted-foreground tabular-nums"> <span className="sr-only">{msg("voice-input.recordingTime", "Recording time")} </span> {formatClock(shownSeconds)} </span> <button type="button" onClick={stop} aria-label={msg("voice-input.stopAndUseRecording", "Stop and use recording")} className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Check aria-hidden="true" className="size-4" /> </button> </> ) : ( <p className="flex min-w-0 flex-1 items-center justify-center gap-2 pe-10 text-sm text-muted-foreground"> <Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" /> {processingLabel} </p> )} </div> )} <span className="sr-only" role="status" aria-live="polite"> {announce} </span> </div> ) } export { VoiceInput, useMicrophoneLevel, formatClock, type VoiceInputProps, type VoiceState }Update the import paths to match your project setup.
Usage
import { VoiceInput, useMicrophoneLevel, formatClock } from "@/components/ballmac/voice-input"The full example is in the Code tab above.
Examples
Button with live glow
Uses your real microphone level. Nothing is recorded or sent.
"use client"
import * as React from "react"
import { ArrowUp } from "lucide-react"
import { VoiceInput, useMicrophoneLevel } from "@/components/ballmac/voice-input"
export default function VoiceInputButton() {
const [listening, setListening] = React.useState(false)
const { level, error } = useMicrophoneLevel(listening)
return (
<div className="grid w-full max-w-md gap-2">
<div className="flex items-center gap-2 rounded-full border bg-card py-1.5 pe-1.5 ps-4 shadow-xs transition-shadow has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/30 motion-reduce:transition-none">
<input
aria-label="Message"
placeholder={listening ? "Listening…" : "Message the assistant"}
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<VoiceInput
className="size-9 [&_button]:size-9"
state={listening ? "listening" : "idle"}
level={level}
onStart={() => setListening(true)}
onStop={() => setListening(false)}
onCancel={() => setListening(false)}
/>
<button
type="button"
aria-label="Send message"
className="inline-flex size-9 items-center justify-center rounded-full bg-primary text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<ArrowUp aria-hidden="true" className="size-4" />
</button>
</div>
<p className="px-2 text-xs text-muted-foreground" role={error ? "alert" : undefined}>
{error ? `Microphone unavailable: ${error}` : "Uses your real microphone level. Nothing is recorded or sent."}
</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
stateCurrent state (controlled). Without it the component keeps its own state and moves idle → listening → processing. | VoiceState | — |
onStartCalled when recording should begin. | () => void | — |
onStopCalled when the person finishes and wants the audio used. | () => void | — |
onCancelCalled when the person throws the recording away. | () => void | — |
levelLoudness from 0 to 1, for example from `useMicrophoneLevel`. Drives the waveform and the button's glow. | number | 0 |
variant"button" is a single round microphone button. "bar" expands into a recorder with waveform, timer, cancel and send. | "button" | "bar" | "button" |
durationSeconds recorded. When omitted the component times itself. | number | — |
processingLabelText in the bar while processing. | string | — |
labelAccessible name of the button when idle. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Starts, and while listening stops and uses the recording |
| Escape | Discards the recording while listening |
| Screen readers | Button names change with state; status is announced politely; the waveform is decorative |
Use with AI
The component draws the control; you record. onStart, onStop, onCancel fire; state (idle | listening | processing) can be controlled. Feed level (0 to 1) from useMicrophoneLevel(active) for a real waveform. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Voice Input (@ballmac/voice-input) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Dictation in a composer
- Voice messages
Not for
- Text entry; use prompt-input
- Playing audio; use audio-player
Registry JSON: https://ui.ballmac.com/r/voice-input.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Prompt Input
An auto-growing chat input: Enter sends, Shift+Enter adds a line (IME-safe), the send button turns into Stop while streaming, with file attachment chips and a toolbar slot.
AI Orb
A glass orb of drifting theme-colored light that shows whether the assistant is idle, listening, thinking or speaking, and swells with a live voice level.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.