# React Speech to Text Input: Mic, Waveform, Fallback

> Build a React speech to text input: a mic that grows into a recording pill with a live waveform, Web Speech API limits per browser and a server fallback.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-speech-to-text-input

## TL;DR

A React speech to text input records with MediaRecorder and shows live words from the Web Speech API where the browser has it: Chrome and Safari do, Firefox does not by default, and Chrome sends the audio to a server for recognition. Treat the browser transcript as a preview, keep the audio Blob, and send it to a transcription endpoint when the browser gives you no words or you need the same result everywhere.

A React speech to text input looks like one button: tap the mic, talk, get text. Building one that holds up takes more. The browser has to ask for the microphone at the right moment, the person needs to see that it is listening, the words should appear as they speak, and the whole thing has to keep working in Firefox, in a Home Screen app on iOS and when the recognition service drops. This guide covers each of those with the code of the Wingo UI [Voice Input](https://wingo-ui.com/components/voice-input), the mic that grows into a recording pill with a live waveform. We build that library, so we are not neutral; browser facts link to their sources.

```bash
npx wingo-ui@latest add voice-input
```

## What does a good speech to text input look like?

A mic button at rest, a recording pill while you talk, the text when you are done. Voice Input has three shapes behind `variant`:

- **`button`** (default): a round mic for composers and toolbars. On tap it grows on a spring into a pill with Cancel, a pulsing red dot, a waveform, a timer and Send, then shrinks back into the mic.
- **`bar`**: a full-width bar that is always visible, the messaging-app layout, with the transcript above it.
- **`orb`**: a voice mode built on the [Matrix Orb](https://wingo-ui.com/components/matrix-orb) that listens, thinks while your promise runs and blooms while the app talks back.

The live words sit above the pill, four lines tall, with the words the recognizer is still unsure of muted. The recording dot stays red whatever `tone` you pick, because red means recording.

> Live demo (Voice Input): Tap the mic and allow the microphone: it grows into a pill with a waveform that follows your voice. Talk, then press Send or Escape to cancel. Try it at [Voice Input](https://wingo-ui.com/components/voice-input) and install it with `npx wingo-ui@latest add voice-input`.

## How does the Web Speech API work in React, and where does it break?

A Web Speech API React hook is short. It wraps `SpeechRecognition`: create a recognizer, set `continuous` and `interimResults`, read `onresult`, stop on unmount. It breaks in the browsers and modes around it:

| Browser | SpeechRecognition | Notes |
|---|---|---|
| Chrome desktop | Yes, `webkit` prefix since 33; MDN lists the unprefixed name from 139 | Audio goes to a server for recognition |
| Safari 14.1+ (macOS, iOS) | Yes, `webkitSpeechRecognition` | Needs Siri or Dictation turned on; not in Home Screen web apps |
| Firefox | No | Off by default; Can I use lists it as disabled |
| Edge | Unclear | MDN's data mirrors Chrome; Can I use lists it as unsupported because no events appear to fire. Test it yourself |

Sources, checked October 2026: [MDN SpeechRecognition](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition), [MDN browser compat data](https://github.com/mdn/browser-compat-data/blob/main/api/SpeechRecognition.json), [Can I use](https://caniuse.com/speech-recognition) and [WebKit bug 225298](https://bugs.webkit.org/show_bug.cgi?id=225298), where the reply from WebKit says the API is not available in SafariViewController or Home Screen web apps "for now".

Three more limits shape the design:

1. **It is not private by default.** MDN says that in Chrome "your audio is sent to a web service for recognition processing, so it won't work offline." Chrome 139 added an experimental `processLocally` option and `SpeechRecognition.available()` / `install()` for on-device language packs on desktop; Android and Safari do not have them in MDN's data.
2. **Chrome stops on its own.** A continuous run ends after a pause, `onend` fires and the transcript stops growing. Voice Input restarts the recognizer from `onend` while you are still recording and joins the finished text of each run.
3. **It needs a gesture and HTTPS.** Browsers open the microphone only from a tap or a key press, and on plain HTTP `navigator.mediaDevices` does not exist. Voice Input asks for permission after the first tap, never on page load, and shows "Voice input isn't available in this browser." when it cannot record at all.

So treat the browser transcript as a fast preview. The audio is the source of truth.

## How do I get the audio Blob and the transcript in React?

When the person presses Send, `onRecordingComplete` receives `{ blob, mimeType, durationMs, transcript }`. The audio comes from `MediaRecorder`, which picks the first type the browser can record: `audio/webm;codecs=opus` in Chrome, `audio/mp4` in Safari versions without WebM recording. The transcript comes from the browser recognizer, or from your `transcript` prop when you stream words from your own service. Turn each source on or off:

- `audio={false}`: no recording, only words and length. Good for plain dictation.
- `transcribe="none"`: no browser recognizer, for server transcription only.
- `lang="en-US"`: the recognition language. Empty falls back to the `locale` prop, the `UIProvider` locale, then the page's `<html lang>` and the browser language.

Return a promise and the pill shows a spinner until it settles. If it rejects, the error line appears with Try again, which sends the same recording again.

## How do I add a dictation button in React to any text field?

This is the dictation button React forms usually want: words only, appended to what is already typed.

```tsx
"use client"

import { useState } from "react"
import { Textarea } from "@/components/ui/textarea"
import { VoiceInput } from "@/components/ui/voice-input"

export function DictatedNote() {
  const [note, setNote] = useState("")

  return (
    <div className="flex flex-col gap-2">
      <Textarea label="Note" value={note} onValueChange={setNote} minRows={4} />
      <div className="flex justify-end">
        <VoiceInput
          audio={false}
          lang="en-US"
          maxDuration={60}
          onRecordingComplete={({ transcript }) => {
            if (transcript) setNote((text) => [text, transcript].filter(Boolean).join(" "))
          }}
        />
      </div>
    </div>
  )
}
```

`onTranscript(text, { final })` fires while the person talks, if you want the words to land in the field live. In Firefox this version records nothing useful, because there is no recognizer and no audio. The next section fixes that.

## How do I fall back to server transcription?

Keep the audio, and when the browser hands you no words, send the Blob to your own endpoint. The client stays the same in every browser:

```tsx
"use client"

import { VoiceInput, type VoiceInputResult } from "@/components/ui/voice-input"

async function transcribeOnServer(result: VoiceInputResult) {
  if (!result.blob) return ""
  // chrome records audio/webm, safari can record audio/mp4: name the file after it
  const ext = result.mimeType?.includes("mp4") ? "mp4" : "webm"
  const body = new FormData()
  body.append("audio", result.blob, `dictation.${ext}`)
  const response = await fetch("/api/transcribe", { method: "POST", body })
  if (!response.ok) throw new Error("Transcription failed")
  const { text } = (await response.json()) as { text: string }
  return text
}

export function Dictation({ onText }: { onText: (text: string) => void }) {
  return (
    <VoiceInput
      lang="en-US"
      maxDuration={120}
      onRecordingComplete={async (result) => {
        // the browser preview when it has one, the server otherwise
        onText(result.transcript || (await transcribeOnServer(result)))
      }}
    />
  )
}
```

The route handler keeps the API key on the server. This one uses OpenAI's transcription endpoint; any provider that takes a file works the same way.

```ts
// app/api/transcribe/route.ts
import OpenAI from "openai"

const openai = new OpenAI()

export async function POST(request: Request) {
  const form = await request.formData()
  const audio = form.get("audio")
  if (!(audio instanceof File)) return Response.json({ error: "No audio" }, { status: 400 })

  const transcription = await openai.audio.transcriptions.create({
    file: audio,
    model: "gpt-transcribe",
  })
  return Response.json({ text: transcription.text })
}
```

As of October 2026, OpenAI's [speech to text guide](https://developers.openai.com/api/docs/guides/speech-to-text) lists `gpt-transcribe` for recorded speech, a 25 MB upload limit and `webm`, `mp4`, `m4a`, `wav` and `mp3` among the accepted formats, so both browsers' recordings go in as they are. `maxDuration` (120 seconds by default) keeps a forgotten recording from growing past what you want to upload.

If you want the same words in every browser, set `transcribe="none"` and always use the server. The pill then says Transcribing while your promise runs.

## How does voice input fit a chat UI?

In a voice input chat UI the mic lives in the composer. The [Prompt Input](https://wingo-ui.com/components/prompt-input) does that with one prop: `voice` adds the mic to the toolbar, the pill takes the toolbar while recording, and the words go in at the caret, replacing a selection. Cancel puts the text back as it was.

```tsx
"use client"

import { PromptInput } from "@/components/ui/prompt-input"

export function Composer({ send }: { send: (text: string) => Promise<void> }) {
  return (
    <PromptInput
      voice
      voiceInputProps={{ lang: "en-US", maxDuration: 60, mode: "hold" }}
      onSubmit={({ text }) => send(text)}
    />
  )
}
```

Pass `onVoice` as well and the mic sends voice messages, `{ blob, mimeType, durationMs, transcript }`, instead of dictating. The [React prompt input guide](https://wingo-ui.com/blog/react-prompt-input) covers the rest of the composer, and the [React AI chat UI guide](https://wingo-ui.com/blog/react-ai-chat-ui) covers the thread around it.

> Live demo (Prompt Input): Tap the mic in the toolbar and talk: the pill replaces the toolbar and the words go in at the caret. Press Enter to send. Try it at [Prompt Input](https://wingo-ui.com/components/prompt-input) and install it with `npx wingo-ui@latest add prompt-input`.

## How do you play back a voice message with its waveform?

A React voice recorder waveform has two jobs: live while recording, still once the message is sent. While recording, Voice Input reads the microphone through an analyser and writes the bar transforms straight to the DOM in a `requestAnimationFrame` loop, never through React state, and pauses it off screen. The same bars ship as `VoiceWaveform` for your own layouts; give it `values` for a still waveform of a recorded note.

For playback, the [Audio Player](https://wingo-ui.com/components/audio-player) has a `compact` variant made for chat bubbles: play, bars you can scrub, time and a speed chip. Without `peaks` it decodes the file with Web Audio once it is on screen, so an object URL from the Blob gets its real waveform.

```tsx
"use client"

import { useEffect, useState } from "react"
import { AudioPlayer } from "@/components/ui/audio-player"

export function VoiceMessage({ blob }: { blob: Blob }) {
  const [src, setSrc] = useState("")

  // one object url per blob, revoked when the bubble goes away
  useEffect(() => {
    const url = URL.createObjectURL(blob)
    setSrc(url)
    return () => URL.revokeObjectURL(url)
  }, [blob])

  return src ? <AudioPlayer variant="compact" src={src} /> : null
}
```

Once the upload finishes, swap the object URL for the stored file's URL and pass `peaks` if your server already computed them.

> Live demo (Audio Player): Play the voice message, then drag across the bars to scrub and tap the speed chip. Try it at [Audio Player](https://wingo-ui.com/components/audio-player) and install it with `npx wingo-ui@latest add audio-player`.

## How do I make voice input accessible on keyboards, screen readers and phones?

Give the mic a real button, announce state changes once and size every target for a thumb. Voice Input does each of these:

- **Keyboard.** Tab to the mic and press Enter or Space. Focus moves to Send while recording, and Escape cancels.
- **Screen readers.** They hear Listening, Transcribing and the recorded length once, never the ticking timer.
- **Phones.** Every target is 44px on touch. `mode="hold"` gives push to talk: hold, let go to send, slide more than 80px toward the start to arm cancel, with a haptic tick (the [Vibration API post](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) explains where that works). The held mic sets `touch-action: none` so the page does not scroll under your thumb.
- **Mis-taps.** A recording shorter than `minDuration` (500 ms by default) is dropped, and a hint asks for a longer message.
- **Errors.** A blocked or missing microphone explains itself in place with Try again.
- **Reduced motion.** The morph becomes a cross-fade and the waveform a still level meter.

## What are the common speech to text pitfalls?

Most bugs come from the permission prompt, leaked microphone streams and testing in one browser.

- **Asking for the microphone on page load.** People deny a prompt they did not ask for, and a denial sticks. Ask on the first tap.
- **Trusting the interim text.** Interim words change. Store the final transcript, or the server's.
- **Forgetting the tracks.** Stop every `MediaStream` track, audio context and recognizer on stop, cancel and unmount, or the browser's recording indicator stays on. The `useVoiceRecorder` hook does this if you build a custom UI on the same engine.
- **One file extension for all browsers.** A Safari recording named `.webm` confuses some APIs. Name it after the mime type.
- **Two mics on one page.** A composer mic and a voice-note mic can both start. In Voice Input a second recorder that starts stops and sends the first, so only one stream holds the microphone.
- **Testing only in Chrome.** Try Firefox, Safari with Dictation off and a Home Screen install before you ship.

## Where to go next

Open the [Voice Input docs](https://wingo-ui.com/components/voice-input), switch `variant`, `mode` and `transcribe` in the playground, then install it with `npx wingo-ui@latest add voice-input`. Voice Input, Prompt Input and Audio Player are part of [Wingo UI Pro](https://wingo-ui.com/pricing). If a coding agent builds your screens, the [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide) shows how to point it at these components, and more posts live in [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents).

## Components in this post

- [Voice Input](https://wingo-ui.com/components/voice-input) (Pro): A React voice input: a mic that grows into a recording pill with a live waveform and transcript, a full-width bar, or a voice orb. Install: `npx wingo-ui@latest add voice-input`
- [Prompt Input](https://wingo-ui.com/components/prompt-input) (Pro): A React prompt input for AI chat: a growing text box with attachments, tool chips, a model picker, dictation and a send and stop button. Install: `npx wingo-ui@latest add prompt-input`
- [Audio Player](https://wingo-ui.com/components/audio-player) (Pro): A React audio player with a waveform you can see and scrub, as a podcast card, a chat voice message or a thin call-log bar. Install: `npx wingo-ui@latest add audio-player`

## FAQ

### Does the Web Speech API work in every browser?

No. MDN lists SpeechRecognition as limited availability: Chrome and Safari 14.1+ ship it (Safari with the webkit prefix), Firefox keeps it off by default, and Safari does not offer it in Home Screen web apps. Feature detect both SpeechRecognition and webkitSpeechRecognition and keep a fallback.

### Is browser speech recognition private or offline?

Not by default. MDN notes that in Chrome the audio is sent to a web service for recognition, so it does not work offline. Chrome 139 added an experimental processLocally option on desktop, but you should not rely on it yet.

### How do I send recorded audio to a speech to text API from React?

Record with MediaRecorder, put the Blob in a FormData body and POST it to your own route handler, which forwards it to the transcription API with the server-side key. Pick the file extension from the recorder's mime type, since Chrome records audio/webm and Safari can record audio/mp4.

### Why does my Web Speech API transcript stop after a pause?

Chrome ends a continuous recognition run on its own after silence, and the onend event fires. Restart it from onend while the user is still recording, and join the finished text of each run so nothing is lost.

### Is the Wingo UI Voice Input free?

No. Voice Input, Prompt Input and Audio Player are part of Wingo UI Pro. As of October 2026, the 75 free items include the Button, Tooltip, Spinner and Matrix Orb that Voice Input builds on.

---

Source: https://wingo-ui.com/blog/react-speech-to-text-input
