Screen Recorder
A real getDisplayMedia screen recorder — surface picker, optional microphone mixdown, live duration and size, and a preview you can download.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/screen-recorder.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScreenRecorder" component on the real
browser getDisplayMedia + MediaRecorder APIs — the visitor picks a screen,
window or tab, records it, then previews and downloads a genuine video file.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: onRecordingComplete?(blob: Blob, meta) where meta is
{ durationMs, mimeType, size, reason: "user" | "auto" | "share-ended" } —
the reason is what lets a consumer tell "they pressed Stop" apart from
"the browser's own Stop sharing chip ended it"; maxDurationMs (default
300_000, auto-stop); microphone: "off" | "optional" | "always" (default
"optional" — a toggle that starts off; "always" mixes the mic in with no
toggle; "off" never touches the mic); systemAudio (default true, asks the
picker for the surface's own audio, which the engine may still refuse);
displaySurface?: "monitor" | "window" | "browser" (an advisory hint, the
visitor still chooses); frameRate? (clamped 1-60); mimeTypes?: string[]
(probed ahead of the built-in candidate list); fileName (default
"screen-recording", extension follows the container actually produced);
showPreview (default true); disabled; labels (partial copy override).
- Clamp the numbers: maxDurationMs below 1000 would end the take on its first
tick, frameRate 0 would ask the compositor for a frozen surface.
Behavior - state machine
- States: idle | requesting | recording | paused | preview | denied | error,
plus two capability blockers that no state transitions into: "insecure"
(window.isSecureContext === false) and "unsupported" (no
navigator.mediaDevices.getDisplayMedia). Read both with
useSyncExternalStore — capability checks cannot happen during render, since
the server has no navigator and would mismatch the markup everywhere. Give
both an optimistic true server snapshot: a pessimistic false paints the
refusal panel for the whole pre-hydration frame at every visitor who can
actually record, so neither the HTTP warning nor the "this browser cannot
capture the screen" panel may be what the server renders. Both blockers
render an explanation and remove the start button entirely — there is
nothing to retry.
- idle -> start -> requesting -> getDisplayMedia({video, audio: systemAudio}).
Call it first, synchronously in the click handler: it needs the click's
transient activation, and putting any other prompt in front of it spends
that activation on the wrong API. The microphone (getUserMedia) is only
requested afterwards, and a refused mic must NOT throw away the surface the
visitor already picked — keep recording and say so in a notice.
- Distinguish a dismissed picker from a real block. Both arrive as
NotAllowedError and only Chromium's message separates them ("Permission
denied by system", "...disallowed by permissions policy") from a bare
"Permission denied". Match the message: a block goes to denied with a
"fix it in the OS / policy / embedding page" hint, everything else goes
back to idle with "the picker was dismissed — nothing was recorded",
because a dismissal is not a denial and must not accuse the visitor of one.
Say plainly in your docs that on engines whose message does not
distinguish them, a block is reported as a dismissal.
- Audio: two audio tracks in one MediaStream is not a mix — MediaRecorder
encodes the first and drops the rest. When both surface audio and mic audio
exist, build an AudioContext, connect both as MediaStreamSources into one
MediaStreamDestination, and hand the recorder that single summed track.
Resume the context if it starts suspended, or the mixdown is silence. With
only one audio source, skip WebAudio entirely and pass the track through.
- Record with recorder.start(1000) so ondataavailable delivers a chunk per
second: that is what makes a live byte counter possible. Sum the chunk
sizes as they arrive and show duration + size while recording.
- Duration is recomputed on every tick from the segment's start stamp
(accumulated + performance.now() - segmentStart), never accumulated a fixed
step per tick. A hidden tab throttles the interval to once a second or
worse; a self-incrementing counter under-reports for the entire time the
tab was hidden. Pause folds the running segment into the accumulated total
and stops the timer, so paused wall-clock time never enters the duration.
- The browser's own "Stop sharing" chip is a first-class path, not an edge
case: listen for `ended` on the surface's video track and finish the take
there. Also wire recorder.onstop at *construction* time rather than at stop
time — when every track ends the recorder stops itself, so a handler
installed only inside your stop() call would never run and the UI would sit
on "Recording" over a frozen frame forever.
- A zero-byte take (stopped before any data arrived) is not a recording:
report it as such instead of offering a download for an unplayable file.
Container probing
- Never hardcode video/webm with vp9. Walk a candidate list through
MediaRecorder.isTypeSupported (vp9+opus, vp8+opus, h264+opus, plain webm,
mp4 avc1, plain mp4) and take the first that passes; wrap each probe in
try/catch since some engines throw on malformed type strings. If nothing
passes, construct MediaRecorder with NO options at all — passing an
unsupported string throws NotSupportedError, while the no-options form
always yields the engine's own default. Derive the download extension from
the container the recorder actually reports, not from what you asked for.
Resource release - the part that must not be skipped
- One teardown function stops the tracks of all three streams (display, mic,
mixed destination), closes the AudioContext and clears video.srcObject. It
runs before each new take, from Stop, from the finalizer and again on
unmount. Until the last track stops, the browser keeps showing "you are
sharing your screen".
- Stop the tracks immediately after calling recorder.stop(), not inside the
async onstop — otherwise the sharing banner lingers while the blob is still
being assembled.
- getDisplayMedia resolves only after the visitor answers the picker, which
easily outlives the component. Guard the resolve with a mounted ref and
stop the returned tracks right there if the component is gone; the unmount
cleanup already ran and could not see a stream that did not exist yet.
Re-arm that ref inside the mount effect or StrictMode's mount -> cleanup ->
mount leaves it false and recording never starts at all. Bump a start-token
counter per start so a superseded request stops its own tracks.
- Exactly one object URL exists per finished take, created in the finalizer
and revoked in exactly one place: before the next take, on discard, and on
unmount. The download is a real anchor with a download attribute pointing
at the same URL the preview player reads, so there is no second blob and
nothing extra to revoke.
- On unmount, clear the recorder's handlers before stopping it: flushing a
final chunk into an unmounted tree is pointless.
Rendering & styling
- Semantic tokens only: bg-card + border for the shell, bg-muted for the
16:9 stage and its overlays, bg-primary / text-primary-foreground for the
start, stop and download actions, text-muted-foreground for hints and
counters, text-destructive + bg-destructive for the error icon and the
live dot. cn() merges the consumer's className.
- The stage is object-contain, never object-cover: a cropped preview hides
the very edges the visitor is checking before they trust what they share.
- The red dot is decoration (aria-hidden): the phase is always spelled out in
words beside it, so nothing depends on seeing a colour. One sr-only
role="status" aria-live="polite" region announces every transition
(including "you stopped sharing, so the recording was finished"); the
running timer is role="timer" aria-live="off" so it does not spam a screen
reader every 200ms.
- Long labels: use wrap-anywhere (plus min-w-0 in the header), not
break-words and not truncate. break-words leaves a flex item's min-content
at the longest word, so a single unbroken 68-character label still pushes
the row out of the card; truncate hides the state text with no second copy
of it anywhere on screen.
- Reduced motion: the live dot pulse is motion-safe only and the requesting
spinner is motion-reduce:animate-none — nothing functional depends on them.
- disabled dims the shell and blocks starting a new take, but never Stop: a
live capture must always be stoppable.
Customization levers
- Length and weight: maxDurationMs (30s for a bug repro, 5 min default),
frameRate (10-15 for text-heavy screens, 30 for motion) and mimeTypes to
force a preferred container — all three change file size without touching
the state machine.
- Audio shape: microphone "off" removes the toggle and the getUserMedia call
entirely; "always" turns it into a narration recorder; systemAudio false
keeps the picker from offering tab audio. Drop the WebAudio mixdown branch
if you only ever need one audio source.
- Surface: displaySurface nudges the picker toward a monitor, a window or a
tab; showPreview false replaces the inline player with a download-only
result panel when the take is going straight to an upload.
- Flow: with onRecordingComplete you can upload immediately and skip the
preview state entirely, or keep the preview as a confirm step. Swap the
download anchor for an upload button if downloads are not the goal.
- Copy: labels overrides any subset (idle / idleHint / requesting /
requestingHint / recording / paused / preview / denied / deniedHint /
error / insecure / insecureHint / unsupported / unsupportedHint /
livePreview / playback / start / stop / pause / resume / download /
discard / again / retry / micOn / micOff / cancelled / shareEnded /
micBlocked / mixFailed / emptyTake / maxReached / recorderFailed /
noVideoTrack) for i18n or brand voice.
- Palette: shell, stage and action tokens are independent — move the primary
action to bg-secondary for a quieter admin skin without touching layout.Concepts
- Stop-sharing is a state transition, not an accident — the surface's video track fires
endedwhen the visitor uses the browser's own sharing bar. The component listens for it and finalizes the take;recorder.onstopis additionally wired at construction time, because a recorder whose tracks have all ended stops itself and would otherwise never reach a handler installed insidestop(). - Dismissal is not denial — a closed picker and a policy-level block are the same
NotAllowedError. Only the message separates them, so the component matches on it: a block gets the "fix it in your OS / policy" panel, everything else goes quietly back to idle. On engines whose message says nothing, a block is reported as a dismissal — stated rather than hidden. - Elapsed time is derived, never accumulated — every tick recomputes
accumulated + (now − segmentStart). A hidden tab throttles the interval, and a counter that added a fixed step per tick under-reports for the whole hidden stretch; measured with a 40s clock jump, the derived timer read 0:41 against 41.98s of truth while the accumulating one read 0:01. - Capability probe over codec faith —
MediaRecorder.isTypeSupportedis walked over a candidate list, and when nothing passes the recorder is constructed with no options at all, which is the only form that cannot throwNotSupportedError. The download extension follows the container the recorder reports, not the one that was requested. - Three streams, one release point — display, microphone and the WebAudio mixdown destination each own tracks, and the browser's sharing banner only disappears once all of them stop. The same teardown runs before a new take, on stop, in the finalizer and on unmount, and a late picker resolve that finds the component gone stops its own tracks.
- One object URL per take — the preview player and the download anchor share the single URL created in the finalizer, and it is revoked before the next take, on discard and on unmount, so the created/revoked ledger always balances at zero live URLs.
QR Scanner
A camera QR reader on the native BarcodeDetector with a pluggable decoder — framed scan region, throttled decode loop, per-value cooldown, and a camera that is always released.
Audio Transcript
A timestamped, speaker-labelled transcript that follows the playhead, hands scrolling back the moment the reader takes over, and seeks to the exact start of any line you click or search for.