Mischief

49 / Agent UI

Voice Input

A microphone that draws what it is hearing, so a live one is told apart from a dead one at a glance.

Ready to record.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/voice-input
import { VoiceInput } from "mischief-ui/voice-input"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/voice-input/voice-input.tsx
"use client" import * as React from "react"import { Mic, MicOff, Square } from "lucide-react" import {  RenderSurface,  useThemeColors,  type SurfaceColor,} from "@/registry/default/render-surface/render-surface"import { cn } from "@/lib/utils" export type VoiceInputStatus =  "unsupported" | "idle" | "requesting" | "listening" | "denied" | "error"

Usage

export function Composer() {
  return (
    <VoiceInput
      maxDuration={60}
      onResult={(recording) => transcribe(recording)}
    />
  )
}

It records; it does not transcribe

Turning speech into text is a service, not a component. Putting one inside something you copy into your own project would decide your vendor, your billing and your privacy posture on your behalf, so this stops at the recording and hands it to you.

<VoiceInput
  onResult={async (recording) => {
    const body = new FormData()
    body.append("audio", recording, "speech.webm")
    setText(await (await fetch("/api/transcribe", { method: "POST", body })).text())
  }}
/>
The recording is a Blob, so it posts like any other file.

Why it draws

A microphone button that only changes colour asks to be trusted. There is no way to tell a working microphone from a muted one, a wrong input device, or a permission that was granted to the page and then revoked by the operating system, until the recording comes back empty.

Drawing the incoming samples settles it in the first half second: if the trace moves when you speak, the microphone the browser handed over is the one you are talking into.

The trace is drawn on the shared render surface, so it takes its colour from your theme, stops when it is scrolled out of view, and survives a lost GPU context like every other surface here.

The states it can be in

A refusal, a missing device and a browser that cannot record are three different problems with three different remedies, so they are three different messages rather than one failure.

StatusWhat happened
unsupportedNo MediaRecorder, so the control is disabled rather than dead
idleReady, nothing held
requestingWaiting on the permission prompt
listeningRecording, and drawing what it hears
deniedPermission refused
errorThe device could not be started

The status is also on the element as data-status, so a composer can style around it without lifting the state.

Letting go of the microphone

The recording indicator staying lit after a component thinks it has stopped is the usual bug here, and it is a privacy one. Every track is stopped and the audio context is closed when recording ends, when the component unmounts, and when a start fails partway through.

API

onResult(recording: Blob) => voidThe audio, once recording stops.
onStart, onStop() => voidEither end of a recording.
onStatusChange(status: VoiceInputStatus) => voidEvery state change, if you are mirroring it elsewhere.
maxDurationnumberSeconds after which it stops on its own. Off by default.
colorstringA theme token for the trace. Defaults to "--primary".
mimeTypestringPreferred container. Ignored when the browser cannot honour it.
labelstringThe button's accessible name when idle.
disabledbooleanTurns the control off without changing its state.

Accessibility

The button carries aria-pressed, so the difference between recording and not is in the accessibility tree rather than only in the icon. Every state change is announced through a polite live region, and the visible message is marked aria-hidden because it is the same sentence: it is said once, not once on screen and once aloud. The trace is decoration and never carries meaning the words do not, which matters because under prefers-reduced-motion it is not drawn at all -- a single painted frame would sit frozen while the microphone was open, so the words and the elapsed time take over instead.