useMediaRecorder
MediaRecorder as a hook — start / pause / resume / stop, a live duration that excludes pauses, a container negotiated against what the browser really supports, permission and unsupported as first-class states, and every track released on unmount.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-media-recorder.jsonPrompt
Build a React + TypeScript "useMediaRecorder" hook (React only — no npm
dependencies; browser MediaStream Recording API).
Contract
- `useMediaRecorder({ constraints = { audio: true }, mimeTypes, bitsPerSecond,
timeSliceMs = 1000, maxDurationMs, onStop, onError } = {})`.
- Returns `{ status, isSupported, permission, stream, durationMs, sizeBytes,
mimeType, recording, error, start, pause, resume, stop, clear }`. The five
commands are referentially stable — every option and callback is read out of a
latest-ref at call time, never captured in a dependency array — so an inline
`constraints` literal and an inline `onStop` arrow are safe, and the commands
can be handed to memoized children.
- `status` is `unsupported | idle | requesting | recording | paused | stopping |
error`. `unsupported` dominates everything (there is nothing to be idle about
in a browser that cannot record); `requesting` is the window while the
permission prompt is up; `stopping` is the gap between `stop()` and the final
chunk; `idle` with a non-null `recording` is the preview state.
- `recording` is `{ blob, url, mimeType, size, durationMs, reason }` where
`reason` is `user | max-duration | track-ended | error`. `url` is an object URL
the hook owns and revokes — on the next `start()`, on `clear()`, and on unmount.
A consumer that outlives the hook keeps `blob` and mints its own URL.
- `error` is `{ kind, name, message, recoverable }`. `kind` is one of
`unsupported`, `insecure-context`, `permission-denied`, `no-device`,
`device-busy`, `capture-failed`, `recorder-failed`, `empty-recording`; `name`
is the DOMException name when the platform supplied one; `message` is the
platform's own text or the built-in English explanation. Branch UI copy on
`kind` / `recoverable`, never on `message`, which is unlocalised prose that
changes between versions.
- `permission` is `unknown | granted | denied` and reports only what this hook
has *observed*. It never queries `navigator.permissions` and never raises a
prompt outside `start()`.
- Export the pieces a consumer needs to build its own support matrix rather than
a second copy that drifts: `isMimeTypeSupported`, `pickSupportedMimeType`,
`extensionForMimeType`, `classifyMediaRecorderError(error, fallbackKind)`,
`MEDIA_RECORDER_ERROR_KINDS`, `MEDIA_RECORDER_ERROR_GUIDE`, and the two default
candidate lists.
Behavior
- Capability detection runs through `useSyncExternalStore(noopSubscribe, detect,
() => true)`, never during render — the server has no `navigator`. The server
snapshot is deliberately optimistic: a pessimistic one would flash "your browser
cannot record" at every visitor whose browser can.
- NEVER start on mount. `start()` is what raises the permission prompt and it
belongs inside a user gesture; a page that grabs the microphone on load spends
its one prompt before anyone knows why. `start()` also re-probes support at call
time and resolves to an `unsupported` error instead of throwing — or
`insecure-context` when `window.isSecureContext` is false, because on an http://
page the API is missing *because* of the origin, and telling that visitor to
change browsers is wrong advice.
- `start()` is latched by a ref that is read AND written synchronously before any
await. A double click, or a held Enter key, would otherwise raise two permission
prompts and leak the stream of whichever request loses. The latch is released by
the finalizer and by every failure path.
- Cancelling matters as much as starting: `stop()` called while `status` is
`requesting` bumps a start token and returns to `idle`, and the stream that
resolves afterwards is orphaned — it stops its own tracks on arrival. The same
token is bumped on unmount, which is the only defence against a prompt that
outlives its component and leaves the recording indicator lit until the tab is
closed.
- The container is negotiated, never assumed. Candidates are probed with
`MediaRecorder.isTypeSupported` (wrapped in try/catch: some engines throw on a
malformed type string rather than answering false) and the first survivor is
passed to the constructor. If none survive, the recorder is built with no
`mimeType` at all and the browser's own choice is reported back through
`mimeType`. A constructor that throws anyway is retried once with no options
before the take is failed. Which list is probed follows the tracks that were
actually handed over (`stream.getVideoTracks().length > 0`), not what was asked
for — a camera request answered with audio only must not be put in a video
container. Chromium writes WebM and cannot write MP4 audio; Safari does the
reverse; a hard-coded "audio/webm;codecs=opus" is the classic Safari throw.
- Duration is derived from an instant the caller captured, never from a clock read
during render (which would differ between SSR and hydration, and between two
renders of the same state). Handlers read `performance.now()` once and pass it
down: `openSegment(at)` on start and resume, `freezeDuration(at)` — idempotent —
on pause and stop. The 200 ms tick recomputes `accumulated + (now - segmentStart)`
rather than adding a fixed step, because a backgrounded tab throttles the
interval to once a second or worse and a stepping counter silently under-reports
every one of those seconds. Paused stretches never enter the total, so the number
the user watched is the number the blob is labelled with.
- `maxDurationMs` is enforced from inside the tick and hands the *same* instant
that tripped it to the stop path, so the saved duration is the one that was on
screen rather than one tick more.
- Every track gets an `ended` listener. A device that is unplugged, revoked from
the browser's own indicator, or taken by another application leaves
`MediaRecorder` happily running against a dead track, recording nothing; the
take ends there with `reason: "track-ended"` so a consumer can tell it apart
from a user stop.
- A recorder `error` event does not throw the take away. The classified failure is
parked, the take is finalized, and if any bytes arrived they are still handed
back: `recording` is populated *and* `error` is set, with `reason: "error"`.
Discarding ten minutes of audio over a failed last second is not a decision a
hook gets to make. A take that produced zero bytes is the opposite case — it
fails as `empty-recording` and never reaches `recording`, because a blob no
player can open is worse than an honest failure.
- Cleanup is total and runs on unmount as well as at the end of every take: the
interval is cleared, the recorder's `dataavailable` / `stop` / `error` listeners
and the tracks' `ended` listeners are detached *first* (so a late event cannot
write into a dead component or mint an object URL nothing will revoke), the
recorder is stopped, every track is stopped — which is what actually puts the
tab's microphone indicator and the OS recording light out — and the object URL
is revoked. StrictMode's mount → cleanup → mount is survivable because nothing
is acquired on mount and the mounted flag is set in the effect body, not only
cleared in cleanup.
Rendering & styling
- The hook renders nothing; the consumer owns the UI. Recommended transport: ONE
button that morphs (Record → Cancel the request → Stop → Record again) rather
than swapping elements, so focus is never dropped when the state changes, plus a
Pause/Resume button and a Discard that only exists once there is a take.
- Keyboard map: the transport is a plain `<button>`, so Space and Enter activate
it and nothing else needs binding. Controls that go unavailable while the user
may be standing on them use `aria-disabled` plus a guard in the handler, never
the native `disabled` attribute — the browser blurs a control the instant it is
disabled and focus falls back to `<body>`. Discard unmounts itself, so it moves
focus to the transport button *before* calling `clear()`.
- ARIA contract: a `role="status" aria-live="polite"` line that is mounted from
the first paint (a live region inserted together with its text announces
nothing) carrying one sentence per state — "Recording, 0:12.4 so far", "Paused
at 0:12.4", "Take ready: 0:12.4, 96.2 KB". The pulsing red dot is decorative and
`aria-hidden`; it uses `motion-safe:animate-pulse` so reduced motion keeps the
meaning without the animation, and the state is never conveyed by the dot alone.
A live `<video srcObject={stream}>` preview is `muted` (an unmuted preview feeds
the microphone back into the speakers and howls) with `playsInline` and a real
`aria-label`; the download is a real `<a download href={recording.url}>` whose
extension comes from the container that was produced, never from the one that
was requested.
- Semantic tokens only: `bg-card`, `bg-muted`, `text-muted-foreground`, `border`,
`bg-destructive/10` + `text-destructive` for the failure panel. Body copy on a
tinted panel is `text-foreground`, not `text-muted-foreground` — measured on the
light theme, muted copy over `bg-destructive/10` lands at 4.1:1, under AA. Never
render a "ready" affordance while `status` is `unsupported` or `error`.
Customization levers
- `constraints` — the one axis that decides everything downstream: `{ audio: true }`
for a voice note, `{ audio: true, video: true }` for a video reply, an exact
`deviceId` for a device picker, `{ audio: { echoCancellation: false } }` for
music. Swap `getUserMedia` for `getDisplayMedia` in the one line that acquires
the stream and the same machine records the screen.
- `mimeTypes` — override the probe order to prefer a container your backend
transcodes cheaply; keep at least one plain `audio/webm` / `video/mp4` fallback
in the list so Safari and Firefox are still served.
- `timeSliceMs` — 1000 keeps `sizeBytes` moving during the take; `0` asks for a
single chunk at stop. Lower it (200-500 ms) and push each `dataavailable` chunk
straight at an upload socket for streaming transcription.
- `maxDurationMs` — the safety cap. 60 s for a voice message, 10 s for a video
reply, omitted for a lecture recorder.
- `bitsPerSecond` — the file-size lever, worth setting when the take is uploaded
over a metered connection.
- `onStop` / `onError` — where the upload, the analytics event and the toast hang
off. Gate the toast on `error.recoverable`: a retry button on a denied
permission can only fail again, so those get the site-settings walkthrough
instead.
- Want hold-to-talk instead of a toggle? Call `start()` on `pointerdown` and
`stop()` on `pointerup`, and keep a `pointermove` guard for `e.buttons === 0` so
a pointer released outside the button still ends the take.Concepts
- One-shot start latch — a ref read and written synchronously at the top of
start(), before anything asynchronous. Two clicks in one tick would otherwise open two permission prompts and leak the stream belonging to the loser; the same latch is what makesstart()idempotent for the whole length of a take. - Elapsed from the segment start stamp — every duration comes from an instant the handler captured and passed down, never from a clock read during render. The tick recomputes
accumulated + (now - segmentStart)instead of adding a step per tick, which is the only reading that survives a backgrounded tab, and pausing folds the running segment into the total so paused stretches are excluded from the number the blob is finally labelled with. - Container negotiation — candidates are probed with
isTypeSupportedand the first survivor wins; when none do, the recorder is built with nomimeTypeand the browser's choice is reported back. Which list is probed follows the tracks actually handed over, not the ones requested — and the download extension follows the container that was produced, so an MP4 blob never leaves as a.webmfile. - Track ended as a stop signal — an unplugged microphone, a permission revoked from the browser's own indicator, or another application seizing the device all end the track while
MediaRecorderkeeps running against nothing. Listening forendedturns that into a finished take withreason: "track-ended", instead of a recording that is silent for the half hour nobody noticed. - Permission as an observed state —
permissionreports whatstart()learned, never what a query said: the hook raises no prompt of its own and touchesnavigator.permissionsnever, so it can sit in a page without spending anyone's one-shot permission budget. - Object URL ledger — exactly one object URL exists per finished take, minted in the finalizer and revoked on the next
start(), onclear(), and on unmount. Handlers are detached before the recorder is stopped so a late event can never mint one that nothing will ever revoke.
useLocale
The reader's resolved locale, time zone, first day of week and numbering system, plus memoised Intl formatters that print the same bytes on the server and after hydration.
useWebSocket
One WebSocket as a hook — a four-state connection machine, jittered backoff reconnects gated by a shouldReconnect predicate, an ordered outbound queue that covers the outage, JSON with a raw escape hatch, and a heartbeat that catches sockets that are open but dead.