Screen recordings of a web app are hard to watch. The interesting part, the button you clicked, is a few dozen pixels on a 1440p frame. Tools like Screen Studio fix this by zooming in on every click, but they're desktop apps. I wanted that inside Chrome, with nothing uploaded anywhere, so I built Bimb, a Chrome extension that records a tab and turns every click into a smooth zoom.
Here's how the pieces fit together, including the parts that took the longest to get right.
1. Recording in Manifest V3
An MV3 extension has a service worker, not a background page, and a service worker can't run MediaRecorder. So recording happens in an offscreen document:
-
Tab recording: the service worker gets a stream id from
chrome.tabCapture, and the offscreen document turns it into aMediaStreamand records it. -
Window or screen recording: I first tried
chrome.desktopCapture, but its stream ids are tied to the frame that asked for them, so the offscreen document couldn't use one requested elsewhere. The offscreen document now callsgetDisplayMedia()itself and shows Chrome's own picker.
Recordings go into IndexedDB (with the unlimitedStorage permission, so Chrome doesn't evict a long video). Nothing leaves the machine.
2. Knowing where the clicks were
Video alone doesn't tell you where someone clicked. During a tab recording, a content script runs in every frame it can reach and reports pointer events in that frame's viewport coordinates.
Iframes make this harder than it sounds. A click inside an embedded iframe has to be mapped to top-page coordinates, which means knowing which <iframe> element that frame lives in. Chrome has no runtime.getFrameId() (that's Firefox-only), so I use a small handshake:
- The child frame registers a random token with the service worker.
- It posts only that token to its parent window.
- The parent's script matches the message source to an iframe element and tells the service worker it owns that token.
The page can see the token but never any coordinates. Frames can also hide inside closed shadow roots, which chrome.dom.openOrClosedShadowRoot() can reach.
Some pages are off-limits: Chrome doesn't let extensions run scripts on chrome:// pages, the Web Store or other extensions. On those the video still records, but there are no clicks to zoom on.
3. Turning clicks into zooms
Each click becomes a zoom segment, with two rules:
- Clicks close in time and space share one segment, and the camera pans between them.
- Far-apart clicks get their own zoom. A long pan across the page while zoomed in is disorienting, so it's better to zoom out and back in.
The zoom finishes arriving just before the click and holds a moment after it, so the viewer sees the button before it's pressed.
4. A camera that's a pure function of time
The camera is a function of time t alone: given the zoom segments and t, it returns the visible rectangle. It's built from eased keyframes, then clamped so the view never leaves the frame.
One detail made a big difference: interpolate the zoom level in log space. Going from 1x to 2x should feel the same as going from 2x to 4x, and plain linear interpolation makes zooming in feel fast at the start and sluggish at the end. Simplified:
// simplified: zoom level between two keyframes
const zoomAt = (a: number, b: number, k: number) =>
Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * k);
Because the camera keeps no state, the editor preview and the exported file are computed by the same code from the same inputs, so they can't drift apart.
5. A cursor that doesn't wobble
Raw pointer samples come in at about 60 Hz and are jittery. Smoothing them with a normal filter adds lag, so the drawn cursor arrives at the button after the click has already happened.
Instead, I resample the path at 120 Hz and run a critically damped spring over it forwards, then backwards (zero-phase, like filtfilt in signal processing). The motion is smooth with no lag, and the cursor sits exactly on the click point at click time.
6. Trimming dead time
Most recordings have pauses: you reading, thinking, waiting for a page to load. Bimb finds stretches with no clicks, keys or pointer movement and suggests cutting them. It's plain gap analysis, no ML, and you confirm each cut.
Cuts are non-destructive. One bug worth sharing: cutting a section introduced audio/video drift, because a cut boundary landed between video frames while the audio was cut at the exact sample. The fix was to snap cut spans to frame boundaries and crop audio chunks at those same boundaries.
7. Exporting in the browser
Export runs in a worker:
- Decode the source at a constant frame rate.
- Draw each frame through the same scene code the preview uses: background, camera, cursor, click ripples, captions and blur regions.
- Encode with WebCodecs.
Mediabunny handles demuxing and muxing for MP4 (H.264 + AAC/Opus) and WebM (VP9/VP8 + Opus), and gifenc handles GIFs. A 1440p60 export runs entirely on your machine.
What I'd tell someone building something similar
- Make rendering a pure function of time. It turned "the preview doesn't match the export" bugs into ordinary unit tests.
- Expect frames and shadow roots everywhere. Real apps (docs sites, dashboards, MDN) nest iframes inside shadow roots.
- Read the MV3 limits before designing. Service workers can't record media, and capture ids are tied to a frame. Both changed my architecture.
Bimb is free to record and export with a watermark; Bimb Pro is a one-time license. You can get it on the Chrome Web Store. I'd love feedback, especially from anyone who has fought the same MV3 capture APIs.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.