A free, browser-only screen recorder. Records, trims, and downloads — never uploads. Single static page, no backend.
The whole pipeline runs in your tab via the Screen Capture API, MediaRecorder, and Web Audio. Recordings live in browser memory until you download them; close the tab and they're gone. There are no analytics, no accounts, and no third-party trackers.
- Record full screen, a window, or a browser tab
- Live preview while recording
- Real audio waveform on the trim track (decoded from the recording itself, not faked)
- In-browser trim with draggable region + handles
- Download as
.webm - Web Share API integration where supported (mobile, macOS Safari) — hidden where it isn't
- Keyboard shortcuts: R to start, Esc to stop / cancel trim
- Dismissible sponsor slot, persisted in
localStorage - Static export — drop the
dist/folder on any static host
| Browser | Video | Audio | Notes |
|---|---|---|---|
| Chrome / Edge | ✓ | ✓ | Full support, including system + tab audio |
| Firefox | ✓ | partial | Often video-only on Linux/macOS — Firefox doesn't expose system audio |
| Safari (desktop) | ✓ | ✗ | getDisplayMedia audio not supported; video records fine |
| Mobile browsers | ✗ | ✗ | The Screen Capture API isn't exposed on mobile yet |
The MIME selection adapts: when a stream has no audio track, the recorder requests video/webm;codecs=vp8 (and friends) without the ,opus suffix that Firefox refuses to honor for audioless streams.
Requires Bun.
bun install
bun run dev # dev server at http://localhost:3000
bun run build # static export to ./dist
bun run lint # eslintThe build produces a static site in dist/ that you can drop on any static host: GitHub Pages, Cloudflare Pages, Netlify, an S3 bucket, etc. There is no backend.
src/app/
layout.tsx # metadata + JetBrains Mono font + JSON-LD
page.tsx # entire app: state machine, MediaRecorder, trim pipeline, UI
globals.css # Base16 Moss palette + component styles
public/
logo-main.svg # also used as favicon
It's a single React client component on purpose — the whole app is one page.
A few things that took multiple iterations to get right:
- Duration tracking.
video.durationon a MediaRecorder-produced WebM blob isInfinityon Firefox (and sometimes on Chrome) because the muxer doesn't write Cues / Segment Info. We bypass the issue entirely by capturingDate.now()deltas aroundMediaRecorder.start()andstop(), and use that as the canonical duration everywhere. - MIME selection is dynamic. Firefox's
MediaRecorder.isTypeSupported("video/webm;codecs=vp8,opus")returnstrueas a static capability check, but the recorder fails silently onstart()if the stream has no audio track. We probestream.getAudioTracks().lengthaftergetDisplayMediaand pick a codec spec that matches. MediaRecorder.onerrormatters. Without it, Firefox failures are invisible —ondataavailableandonstopsimply never fire. We surface errors as a typed error view with codes (ERR_PERMISSION_DENIED,ERR_API_UNSUPPORTED,ERR_RECORDING_FAILED).- Trim is real-time re-encode. We attach the recorded blob to a hidden
<video>, route it through a 2D canvas (captureStream) plus anAudioContext.createMediaElementSourcegraph, and feed the combined output into a secondMediaRecorderfor the duration of the kept range. Slow, but works without WASM and stays inside the browser. - Waveform is decoded from the actual audio.
AudioContext.decodeAudioDataon the recorded blob, peak-bucketed into 80 bars. When there's no audio track (Firefox/Linux), we render no bars — better than a fake signal. - Web Share is feature-detected.
navigator.canShare({ files: [...] })— the Share button only renders when the browser actually supports sharing files. Most desktop browsers fail this check.
- Design exported from claude.ai/design — see the chat transcripts in the design bundle for the iteration history. Base16 Moss palette, JetBrains Mono.
- Built with Next.js 16 (static export) + React 19.
MIT © badfd explorations