makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
onSend(seconds: number) => voidCalled with the clip length when the user presses send.
placeholderstringText in the idle field. Defaults to "Message…".
classNamestringExtra 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>
  )
}

On this page