Screen Recorder
Record your screen, a window, or a tab right in the browser with getDisplayMedia + MediaRecorder: live preview, a running timer, playback, and a webm download. Nothing leaves the device.
Your screen recording preview shows here
Chromium & Firefox desktop · the OS picks the screen/window/tab
Usage
import { ScreenRecorder } from "~/components/media/screen-recorder";<ScreenRecorder />
Or build your own UI on the useScreenRecorder hook:
import { useScreenRecorder } from "~/hooks/use-screen-recorder";function Recorder() {const { status, start, stop, recordedUrl } = useScreenRecorder();return status === "recording"? <button onClick={stop}>Stop</button>: <button onClick={start}>Record screen</button>;}
getDisplayMedia, then MediaRecorder
Two APIs do the work. navigator.mediaDevices.getDisplayMedia() prompts the OS picker and hands back a MediaStream of whatever the user chose. Feed that stream to a MediaRecorder, collect its dataavailable chunks, and on stop wrap them in a Blob you can play back or download. It all happens locally, so nothing is uploaded anywhere.
Two things are easy to forget. Stop every track when you're done (stream.getTracks().forEach((t) => t.stop())) or the browser keeps showing its "sharing" banner. And listen for the video track's ended event, because the user can stop the share from the browser's own UI, not just your button.
// 1. Ask for the screen/window/tab streamconst stream = await navigator.mediaDevices.getDisplayMedia({ video: true });// 2. Record it to chunksconst chunks = [];const rec = new MediaRecorder(stream, { mimeType: "video/webm" });rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);// 3. On stop, build a blob you can play or downloadrec.onstop = () => {const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" }));stream.getTracks().forEach((t) => t.stop()); // release the capture};rec.start();
Props
| prop | type | default | description |
|---|---|---|---|
| className | string | — | Extra classes on the wrapper. |
Inspired by Bilal Hussain.
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { useEffect, useRef, useState } from "react";import { Monitor, Square, Download, RotateCcw } from "lucide-react";import { cn } from "~/lib/utils";import { useScreenRecorder } from "~/hooks/use-screen-recorder";/*** Screen recorder on getDisplayMedia + MediaRecorder: start capture, preview the* stream live, stop, then play back and download the webm. Built on the* useScreenRecorder hook. Inspired by Bilal Hussain (@BilliCodes).*/export function ScreenRecorder({ className }: { className?: string }) {const { status, error, durationMs, stream, recordedUrl, start, stop, reset } =useScreenRecorder();const videoRef = useRef<HTMLVideoElement>(null);const [mounted, setMounted] = useState(false);useEffect(() => setMounted(true), []);// Attach the live stream while recording, the recorded file once stopped.useEffect(() => {const v = videoRef.current;if (!v) return;if (status === "recording" && stream) {v.srcObject = stream;v.muted = true;v.play().catch(() => {});} else if (status === "stopped" && recordedUrl) {v.srcObject = null;v.src = recordedUrl;v.muted = false;}}, [status, stream, recordedUrl]);const showVideo = status === "recording" || status === "stopped";return (<div className={cn("w-full max-w-xl", className)}><div className="relative aspect-video overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-black"><videoref={videoRef}className={cn("h-full w-full object-contain", !showVideo && "hidden")}playsInlinecontrols={status === "stopped"}/>{!showVideo && (<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center text-[hsl(var(--muted-foreground))]"><Monitor className="size-8 opacity-60" /><p className="text-sm">Your screen recording preview shows here</p></div>)}{status === "recording" && (<div className="absolute left-3 top-3 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 font-mono text-xs text-white backdrop-blur"><span className="size-2.5 animate-pulse rounded-full bg-red-500" />REC {fmt(durationMs)}</div>)}</div><div className="mt-4 flex flex-wrap items-center gap-3">{status !== "recording" ? (<buttontype="button"onClick={start}className="inline-flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-opacity hover:opacity-90 active:translate-y-px"><Monitor className="size-4" />{status === "stopped" ? "Record again" : "Start recording"}</button>) : (<buttontype="button"onClick={stop}className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90 active:translate-y-px"><Square className="size-4" fill="currentColor" />Stop recording</button>)}{status === "stopped" && recordedUrl && (<><ahref={recordedUrl}download="screen-recording.webm"className="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-4 py-2.5 text-sm font-medium transition-colors hover:bg-[hsl(var(--muted))]"><Download className="size-4" /> Download .webm</a><buttontype="button"onClick={reset}className="inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:text-[hsl(var(--foreground))]"><RotateCcw className="size-4" /> Clear</button></>)}</div>{error && (<p className="mt-3 text-sm text-red-400">{error}</p>)}{mounted && status === "idle" && !error && (<p className="mt-3 text-xs text-[hsl(var(--muted-foreground))]">You'll be asked which screen, window, or tab to share. Nothing leavesyour device — the recording is built in the browser.</p>)}</div>);}function fmt(ms: number): string {const total = Math.floor(ms / 1000);const m = Math.floor(total / 60);const s = total % 60;return `${m}:${s.toString().padStart(2, "0")}`;}
import { useCallback, useEffect, useRef, useState } from "react";export type RecorderStatus = "idle" | "recording" | "stopped";/*** Screen recording on the MediaStream Recording API: getDisplayMedia for the* screen/window/tab stream, MediaRecorder to capture it to a webm blob. Returns* the live stream (attach to a <video> for preview), the recorded object URL,* elapsed time, and start/stop/reset controls.** const { status, stream, recordedUrl, start, stop } = useScreenRecorder();*/export function useScreenRecorder() {const [status, setStatus] = useState<RecorderStatus>("idle");const [error, setError] = useState<string | null>(null);const [recordedUrl, setRecordedUrl] = useState<string | null>(null);const [durationMs, setDurationMs] = useState(0);const [stream, setStream] = useState<MediaStream | null>(null);const streamRef = useRef<MediaStream | null>(null);const recorderRef = useRef<MediaRecorder | null>(null);const chunksRef = useRef<Blob[]>([]);const tickRef = useRef<number | null>(null);const startTs = useRef(0);const isSupported =typeof navigator !== "undefined" &&!!navigator.mediaDevices?.getDisplayMedia &&typeof window !== "undefined" &&"MediaRecorder" in window;const stopTimer = () => {if (tickRef.current) {clearInterval(tickRef.current);tickRef.current = null;}};const cleanupStream = () => {streamRef.current?.getTracks().forEach((t) => t.stop());streamRef.current = null;setStream(null);};const stop = useCallback(() => {stopTimer();if (recorderRef.current && recorderRef.current.state !== "inactive")recorderRef.current.stop();}, []);const start = useCallback(async () => {setError(null);if (!isSupported) {setError("Screen recording isn't supported in this browser.");return;}let media: MediaStream;try {media = await navigator.mediaDevices.getDisplayMedia({video: true,audio: false,});} catch (e) {const name = (e as { name?: string })?.name;setError(name === "NotAllowedError"? "Permission denied — pick a screen, window, or tab to record.": "Couldn't start screen capture.",);return;}if (recordedUrl) {URL.revokeObjectURL(recordedUrl);setRecordedUrl(null);}streamRef.current = media;setStream(media);chunksRef.current = [];const mime =["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"].find((m) => MediaRecorder.isTypeSupported(m),) ?? "";const rec = new MediaRecorder(media, mime ? { mimeType: mime } : undefined);recorderRef.current = rec;rec.ondataavailable = (e) => {if (e.data.size) chunksRef.current.push(e.data);};rec.onstop = () => {const blob = new Blob(chunksRef.current, { type: mime || "video/webm" });setRecordedUrl(URL.createObjectURL(blob));cleanupStream();setStatus("stopped");};// The browser's own "Stop sharing" ends the track — treat it as stop.media.getVideoTracks()[0]?.addEventListener("ended", stop);rec.start();startTs.current = Date.now();setDurationMs(0);tickRef.current = window.setInterval(() => setDurationMs(Date.now() - startTs.current),250,);setStatus("recording");}, [isSupported, recordedUrl, stop]);const reset = useCallback(() => {if (recordedUrl) URL.revokeObjectURL(recordedUrl);setRecordedUrl(null);setDurationMs(0);setError(null);setStatus("idle");}, [recordedUrl]);useEffect(() => () => {stopTimer();cleanupStream();if (recordedUrl) URL.revokeObjectURL(recordedUrl);},// eslint-disable-next-line react-hooks/exhaustive-deps[],);return {status,error,isSupported,durationMs,stream,recordedUrl,start,stop,reset,};}