Media

Screen Recorder

A real getDisplayMedia screen recorder — surface picker, optional microphone mixdown, live duration and size, and a preview you can download.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertTriangle,
  Download,
  Loader2,
  Mic,
  MicOff,
  MonitorPlay,
  Pause,
  Play,
  RotateCcw,
  ShieldOff,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/screen-recorder.json

Prompt

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 ended when the visitor uses the browser's own sharing bar. The component listens for it and finalizes the take; recorder.onstop is additionally wired at construction time, because a recorder whose tracks have all ended stops itself and would otherwise never reach a handler installed inside stop().
  • 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 faithMediaRecorder.isTypeSupported is 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 throw NotSupportedError. 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.

On This Page