Voice Composer
An animated voice composer with a microphone and a live waveform.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/voice-composer.jsonDependencies
- motion
- lucide-react
Interaction
Press the microphone and the composer morphs into a recorder with a live waveform and a running timer. Send finishes the clip, the cross cancels it. The waveform is simulated, so no microphone permission is requested.
Props
| Prop | Type | Description |
|---|---|---|
| onSend | (seconds: number) => void | Called with the clip length when the user presses send. |
| placeholder | string | Text in the idle field. Defaults to "Message…". |
| className | string | Extra classes merged onto the root element. |
How to use
import VoiceComposer from "@/components/ui/voice-composer"
export function Example() {
return <VoiceComposer onSend={(s) => console.log(s)} />
}Source code
components/ui/voice-composer.tsx
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { ArrowUp, Check, Mic, Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"
export interface VoiceComposerProps {
placeholder?: string
lang?: string
onSend?: (message: string) => void
onAdd?: () => void
className?: string
}
type Recognition = {
continuous: boolean
interimResults: boolean
lang: string
start(): void
abort(): void
onresult: ((e: any) => void) | null
onend: (() => void) | null
}
/** Rolling dotted line with a live microphone waveform. */
function Wave({
analyser,
className,
}: {
analyser: AnalyserNode | null
className?: string
}) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = ref.current
const context = canvas?.getContext("2d")
if (!canvas || !context) return
const dpr = window.devicePixelRatio || 1
const fit = () => {
canvas.width = canvas.clientWidth * dpr
canvas.height = canvas.clientHeight * dpr
}
fit()
const resizeObserver = new ResizeObserver(fit)
resizeObserver.observe(canvas)
const buffer = new Uint8Array(analyser ? analyser.fftSize : 0)
const history: number[] = []
const STEP = 45
const gap = 5 * dpr
let last = 0
let raf = 0
const frame = (time: number) => {
if (analyser && time - last >= STEP) {
last = time
analyser.getByteTimeDomainData(buffer)
let sum = 0
for (const value of buffer) {
const deviation = (value - 128) / 128
sum += deviation * deviation
}
history.push(
Math.min(1, Math.sqrt(sum / buffer.length) * 6)
)
if (history.length > 400) history.shift()
}
const slide = Math.min(1, (time - last) / STEP) * gap
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillStyle = getComputedStyle(canvas).color
for (
let i = 0, x = canvas.width - 2 * dpr - slide;
x > 0;
i++, x -= gap
) {
const value = history[history.length - 1 - i] ?? 0
if (value > 0.06) {
const height = Math.max(
5 * dpr,
value * canvas.height * 0.9
)
context.globalAlpha = 0.9
context.beginPath()
context.roundRect(
x - dpr,
(canvas.height - height) / 2,
2 * dpr,
height,
dpr
)
context.fill()
} else {
context.globalAlpha = 0.35
context.beginPath()
context.arc(
x,
canvas.height / 2,
dpr,
0,
Math.PI * 2
)
context.fill()
}
}
raf = requestAnimationFrame(frame)
}
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
resizeObserver.disconnect()
}
}, [analyser])
return (
<canvas
ref={ref}
aria-hidden="true"
className={cn("w-full", className)}
/>
)
}
const round =
"grid size-8 shrink-0 place-items-center rounded-full transition-colors duration-200"
const blue =
"bg-blue-600 text-white hover:bg-blue-500 active:scale-90 disabled:cursor-not-allowed disabled:opacity-50"
const subtleButton =
"bg-neutral-100 text-neutral-600 hover:bg-neutral-200 hover:text-neutral-950 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
export default function VoiceComposer({
placeholder = "Ask anything, or type / for skills",
lang = "en-US",
onSend,
onAdd,
className,
}: VoiceComposerProps) {
const [phase, setPhase] = useState<
"idle" | "listening" | "sending"
>("idle")
const [text, setText] = useState("")
const [heard, setHeard] = useState("")
const [note, setNote] = useState("")
const [analyser, setAnalyser] =
useState<AnalyserNode | null>(null)
const area = useRef<HTMLTextAreaElement>(null)
const rec = useRef<Recognition | null>(null)
const media = useRef<{
stream: MediaStream
ctx: AudioContext
} | null>(null)
const heardRef = useRef("")
const stopAudio = useCallback(() => {
if (rec.current) {
rec.current.onend = null
rec.current.abort()
rec.current = null
}
if (media.current) {
media.current.stream
.getTracks()
.forEach((track) => track.stop())
media.current.ctx.close().catch(() => {})
media.current = null
}
setAnalyser(null)
}, [])
useEffect(() => stopAudio, [stopAudio])
useEffect(() => {
const element = area.current
if (!element) return
element.style.height = "auto"
element.style.height = `${element.scrollHeight}px`
}, [text, phase])
useEffect(() => {
if (!note) return
const timeout = setTimeout(() => setNote(""), 3000)
return () => clearTimeout(timeout)
}, [note])
const start = async () => {
if (phase !== "idle") return
setNote("")
setHeard("")
heardRef.current = ""
setPhase("listening")
try {
const stream =
await navigator.mediaDevices.getUserMedia({
audio: true,
})
const ctx = new AudioContext()
const node = ctx.createAnalyser()
node.fftSize = 512
ctx.createMediaStreamSource(stream).connect(node)
media.current = { stream, ctx }
setAnalyser(node)
} catch {
setPhase("idle")
setNote("Microphone access is blocked.")
return
}
const SR =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition
if (SR) {
const recognition: Recognition = new SR()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = lang
recognition.onresult = (event) => {
let transcript = ""
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
heardRef.current = transcript.trim()
setHeard(transcript.trim())
}
recognition.onend = () => {
if (rec.current !== recognition) return
try {
recognition.start()
} catch {}
}
try {
recognition.start()
rec.current = recognition
} catch {
setNote("Could not start voice typing.")
}
}
}
const cancel = () => {
stopAudio()
setPhase("idle")
setHeard("")
heardRef.current = ""
}
const confirm = () => {
const said = heardRef.current
const supported = !!rec.current
stopAudio()
setPhase("idle")
if (said) {
setText((previous) =>
previous ? `${previous.trimEnd()} ${said}` : said
)
} else if (!supported) {
setNote("Voice typing isn't supported in this browser.")
}
requestAnimationFrame(() => area.current?.focus())
}
const send = () => {
const value = text.trim()
if (!value || phase !== "idle") return
setPhase("sending")
onSend?.(value)
setTimeout(() => {
setText("")
setPhase("idle")
}, 450)
}
return (
<div className={cn("w-full max-w-xl", className)}>
<LayoutGroup>
<motion.div
layout
transition={{
type: "spring",
stiffness: 420,
damping: 36,
}}
animate={
phase === "sending"
? {
opacity: 0.45,
scale: 0.985,
filter: "blur(5px)",
}
: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
}
}
className="overflow-hidden rounded-3xl border border-neutral-200 bg-white p-3 text-neutral-900 shadow-sm transition-colors duration-200 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100"
>
<AnimatePresence mode="wait" initial={false}>
{phase === "listening" ? (
<motion.div
key="listening"
initial={{
opacity: 0,
filter: "blur(6px)",
}}
animate={{
opacity: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
filter: "blur(6px)",
transition: { duration: 0.12 },
}}
className="px-2"
aria-live="polite"
>
<p
className={cn(
"line-clamp-2 min-h-6 text-[15px]",
heard
? "text-neutral-900 dark:text-neutral-100"
: "text-neutral-500 dark:text-neutral-400"
)}
>
{heard || "Listening…"}
</p>
<div className="mt-3 flex items-center gap-3">
<Wave
analyser={analyser}
className="h-8 min-w-0 flex-1 text-neutral-800 dark:text-neutral-200"
/>
<button
type="button"
onClick={cancel}
aria-label="Cancel dictation"
className={cn(round, subtleButton)}
>
<X className="size-4" />
</button>
<button
type="button"
onClick={confirm}
aria-label="Use dictation"
className={cn(round, blue)}
>
<Check className="size-4" />
</button>
</div>
</motion.div>
) : (
<motion.div
key="idle"
initial={{
opacity: 0,
filter: "blur(6px)",
}}
animate={{
opacity: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
filter: "blur(6px)",
transition: { duration: 0.12 },
}}
>
<textarea
ref={area}
rows={1}
value={text}
placeholder={placeholder}
onChange={(event) =>
setText(event.target.value)
}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey
) {
event.preventDefault()
send()
}
}}
className="block max-h-40 w-full resize-none bg-transparent px-2 py-1.5 text-[15px] text-neutral-900 outline-none placeholder:text-neutral-500 dark:text-neutral-100 dark:placeholder:text-neutral-500"
/>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={onAdd}
aria-label="Add"
className={cn(round, subtleButton)}
>
<Plus className="size-4" />
</button>
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={start}
aria-label="Dictate"
className={cn(
round,
"text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-100"
)}
>
<Mic className="size-4" />
</button>
<button
type="button"
onClick={send}
aria-label="Send"
disabled={!text.trim()}
className={cn(round, blue)}
>
<ArrowUp className="size-4" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</LayoutGroup>
<p
role="status"
className="h-5 px-3 pt-1.5 text-xs text-neutral-500 dark:text-neutral-400"
>
{note}
</p>
</div>
)
}