Skip to content
← components

Screen Recorder

new

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

example.tsx
import { ScreenRecorder } from "~/components/media/screen-recorder";
<ScreenRecorder />

Or build your own UI on the useScreenRecorder hook:

use-screen-recorder.tsx
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.

record.ts
// 1. Ask for the screen/window/tab stream
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
// 2. Record it to chunks
const 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 download
rec.onstop = () => {
const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" }));
stream.getTracks().forEach((t) => t.stop()); // release the capture
};
rec.start();

Props

proptypedefaultdescription
classNamestringExtra classes on the wrapper.

Inspired by Bilal Hussain.

The 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, …).

components/media/screen-recorder.tsx
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">
<video
ref={videoRef}
className={cn("h-full w-full object-contain", !showVideo && "hidden")}
playsInline
controls={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" ? (
<button
type="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>
) : (
<button
type="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 && (
<>
<a
href={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>
<button
type="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 leaves
your 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")}`;
}
hooks/use-screen-recorder.ts
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,
};
}