I moved my daily driver to Linux Mint Cinnamon and loved almost everything about it, except one thing I touch hundreds of times a day: the trackpad in Chrome. On a Mac or a Windows precision touchpad, pinching a page gives you a visual magnifier. You zoom into a diagram, look at it, pinch back out, and the layout never moves. On my Mint setup, what I got instead was stepped, chunky scrolling and a zoom that went through Chrome's page zoom, which reflows the whole page. Text rewraps, sidebars jump, and you lose your place.
So I wrote Mint Zoom (the manifest calls it "Linux Smooth Pinch-to-Zoom"), a Manifest V3 extension that does two things:
- A purely visual zoom that magnifies the page around your cursor without touching layout.
- An optional smoothing engine that turns the stepped wheel events from a Linux trackpad into eased scrolling.
The whole thing is one content script, content.js, about 400 lines of plain JavaScript, plus a small popup for settings.
Why not just use browser zoom?
Chrome's built-in zoom changes the CSS pixel ratio. Every element is laid out again at the new size, which is exactly what you want for accessibility and exactly what you don't want when you're squinting at one corner of a chart. I wanted the macOS-style behaviour: the page is a fixed image and you move a magnifying glass over it.
The cheapest way to get that in a browser is a CSS transform on the root element. Transforms happen after layout, in the compositor, so the page doesn't reflow at all. That one decision drives everything else in the extension.
Changes layout size. Text rewraps, responsive breakpoints can flip, scroll position drifts. Zooms toward the top-left, not your cursor.
A matrix() transform on html. No reflow, GPU-composited, anchored to the exact point under your cursor. Panning is done by moving the matrix offsets.
How an event flows through the script
Everything hangs off a single wheel listener registered on window in the capture phase with passive: false. Capture means I see the event before the page's own handlers do, and non-passive means I'm allowed to call preventDefault() on it. The listener then picks one of three paths:
- wheel eventcapture phaseIntercepted before page scripts.
- Modifier held?zoom pathUpdate zoom around the cursor.
- Zoomed in?pan pathMove the matrix offsets instead of scrolling.
- Smooth scroll on?scroll pathEase toward a target scroll position.
- rAFone write per frameApply transform or scroll.
The gesture it listens for is a wheel event with a modifier held, Ctrl by default. The popup lets you switch to Alt, Shift, or Super, and a slider sets the sensitivity from 0.001 to 0.050 zoom per unit of deltaY. Settings live in chrome.storage.sync, and the content script listens to storage.onChanged, so changing the slider takes effect on open tabs immediately with no reload.
The cursor-anchored zoom equation
The part I'm happiest with is also the part that took the most fiddling. A naive zoom scales around the origin, so the thing you were pointing at slides away from the cursor. The README calls the fix "pointer locking". The idea: find the document coordinate currently under the cursor, change the scale, then solve for the translation that puts that same point back under the cursor.
let cx = e.clientX; let cy = e.clientY; let sx = window.scrollX; let sy = window.scrollY; // document-space point currently under the cursor let dx = (cx + sx - tx) / previousZoom; let dy = (cy + sy - ty) / previousZoom; currentZoom = Znew; // translation that keeps that point under the cursor at the new scale let newTx = cx + sx - dx * currentZoom; let newTy = cy + sy - dy * currentZoom;
With transform-origin: 0 0, a point d in the document lands at d * zoom + t on screen. Set that equal to the cursor position before and after the zoom change and the equation above falls out. The result is that whatever is under your pointer stays put while everything else grows around it.
Then comes clamping. Without it you can translate the page so far that you're staring at empty space beyond its edge. The bounds come from the viewport size and the full scrollWidth and scrollHeight of the document:
let minTx = W + sx - currentZoom * DW; let maxTx = sx; if (minTx > maxTx) minTx = maxTx; tx = Math.max(minTx, Math.min(maxTx, newTx));
When zoom returns to exactly 1.0, tx and ty reset to zero and the transform, origin, and will-change hints are all removed, so the page goes back to its original state with nothing left over.
Writing the transform once per frame
Trackpads fire wheel events faster than the display refreshes. Writing a style on every event would be wasted work, so updateTransform() sets a flag and schedules a single requestAnimationFrame callback that applies the latest state:
document.documentElement.style.willChange = 'transform'; document.documentElement.style.transformOrigin = '0 0'; document.documentElement.style.transform = `matrix(${currentZoom}, 0, 0, ${currentZoom}, ${tx}, ${ty}) translateZ(0)`;
will-change and the translateZ(0) nudge Chrome into giving the page its own compositor layer, so zooming and panning become GPU transforms rather than repaints.
Panning, and handing control back
Once you're zoomed in, a plain two-finger swipe should move the magnifier, not scroll the page underneath it. So when currentZoom > 1 and no modifier is held, the delta is subtracted from tx/ty and clamped against the same bounds.
The detail that makes this feel right is what happens at the edge. The script only calls preventDefault() if the clamped translation actually changed. If you've panned all the way to the bottom of the magnified view, the clamp swallows the movement, the event is left alone, and Chrome's native scroll takes over. You move the magnifier until it can't go further, then the page itself starts scrolling.
Smoothing out a stepped trackpad
The second feature, off by default and toggled from the popup, is the smooth-scroll engine. Instead of letting each wheel event jump the page, it accumulates a target position and eases toward it:
// Snappier easing (0.35) removes the "cluttering"/lag feeling on trackpads let newSx = sx + (targetScrollX - sx) * 0.35; let newSy = sy + (targetScrollY - sy) * 0.35; if (Math.abs(targetScrollX - newSx) < 0.5 && Math.abs(targetScrollY - newSy) < 0.5) { scrollTargetElement.scrollTo(targetScrollX, targetScrollY); isScrolling = false; return; }
Each frame covers 35% of the remaining distance, and once it's within half a pixel it snaps to the target and stops the loop. I tried softer factors first. They looked lovely in a demo and felt laggy in real use, because your fingers have stopped but the page is still drifting. 0.35 was where it stopped feeling like the page was on a rubber band.
Two practical details:
- The right element. Lots of modern pages scroll an inner
div, not the document.getScrollableElement()walks up from the event target looking for an ancestor withoverflow-yoroverflow-xset toauto/scrollthat actually overflows, and falls back todocumentElement. - Scroll chaining. If that container is already at its edge in the dominant direction, the script returns without
preventDefault(), so the outer page scrolls naturally. It also re-syncs its target from the realscrollTopwhenever it isn't animating, so dragging the scrollbar doesn't leave a stale target behind.
What I'm working on now
On my machine there's a second, not-yet-pushed module in content.js: an Instagram Reels optimizer. Reels renders a blurred duplicate of the video behind the real one. On my laptop's integrated GPU, that extra decode-and-blur was the difference between smooth playback and dropped frames. The optimizer hides filter: blur wrappers, pauses and mutes the duplicate video elements inside them, and zeroes out background canvases, all driven by a MutationObserver while you're on a /reel/ or /reels/ path.
While building it I learned something about content scripts the hard way. My first attempt at detecting single-page-app navigation wrapped history.pushState. But content scripts run in an isolated JavaScript world, so the page's calls to pushState never go through my wrapper. The thing that actually catches route changes is the one-second URL poll I added as a fallback. The proper fix is to inject a small script into the page's main world, or to listen for navigation from a service worker. That's next, along with publishing the optimizer and doing some real profiling instead of judging smoothness by feel.
| Setting | Storage key | Default |
|---|---|---|
| Zoom modifier | modifierKey | ctrlKey |
| Zoom sensitivity | zoomSpeed | 0.005 |
| Smooth scrolling | smoothScrollEnabled | off |
Installing it
It isn't on the Chrome Web Store. Clone the repo, open chrome://extensions, turn on Developer mode, click Load unpacked, and pick the folder. Then hold Ctrl and swipe with two fingers.
git clone https://github.com/shubhrgunjan/Mint_Zoom
The main thing I took from this project: when your OS doesn't give you a feature, the browser often gives you enough to build it yourself. In this case that was one transform, one equation, and a disciplined requestAnimationFrame loop.