Put your cursor over a small piece of text in an editor and zoom in. You should be able to keep looking at the same text.
It's a small test, but it catches a surprising amount of unfinished zoom behavior. The percentage changes, the document gets bigger, and the detail you wanted to inspect slides somewhere else.
I build Repceipt, a receipt editor that runs in the browser. Receipts have plenty of small text packed into a narrow space. Zooming in on a price or a line of text needs to keep that detail within reach.
The implementation uses DOM elements inside a scaled wrapper. The interesting part is how the scale interacts with coordinates and scrolling.
There are two sets of coordinates to keep straight: positions within the document, and positions on screen. Here, screen coordinates mean CSS pixels relative to the browser viewport.
A block at document position x = 300 stays at x = 300 when you zoom. At 50%, it appears 150 CSS pixels from the document's left edge. At 200%, it appears 600 pixels from that edge.
Keeping those stored coordinates stable means zoom can change the view without rewriting every block.
Our wrapper scales from its top-left corner. Around it, an untransformed frame gets an explicit width of documentWidth * zoom and height of documentHeight * zoom.
That outer frame matters. A CSS transform changes rendering while leaving normal flow layout intact. Transforms can affect overflow, but applying scale() doesn't rewrite the element's layout dimensions. The CSS transforms specification describes that distinction.
The explicit frame gives centering and scrolling a box whose layout dimensions match the displayed document size.
Once that's in place, zooming around the cursor has three steps:
- Find the document point currently beneath the cursor.
- Apply the new zoom.
- Adjust scrolling to put that point back beneath the cursor.
For a document scaled uniformly from the top-left corner, the first calculation is simple:
const documentX = (cursorX - frameLeft) / currentZoom; Suppose the frame starts at screen position 100, the cursor is at 250, and zoom is 50%. The cursor is over document coordinate 300:
(250 - 100) / 0.5 = 300 Zoom to 100%, keeping the same frame origin, and that point would appear at screen position 400. Scrolling right by 150 brings it back to 250.
In the real editor, the frame origin can change too. A centered document gets wider as you zoom, and its left edge moves. So we measure the frame again after the update.
Here's the geometry from the wheel handler, with the event setup and zoom-limit calculation left out:
import { flushSync } from 'react-dom'; // Inside the handler, after choosing nextZoom: const before = frame.getBoundingClientRect(); const documentX = (event.clientX - before.left) / currentZoom; const documentY = (event.clientY - before.top) / currentZoom; flushSync(() => setZoom(nextZoom)); const after = frame.getBoundingClientRect(); viewport.scrollLeft += after.left + documentX * nextZoom - event.clientX; viewport.scrollTop += after.top + documentY * nextZoom - event.clientY; The final two lines calculate where the original document point has landed, compare it with the cursor, and scroll by the difference.
Both measurements use getBoundingClientRect(), which gives viewport-relative positions and accounts for scrolling. That keeps them in the same coordinate system as clientX and clientY.
The order matters. Measuring immediately after requesting a React update can give you the old layout. We use flushSync at this boundary so the second measurement sees the updated DOM. It forces synchronous work and can affect performance, so it's a deliberate choice in this handler.
A toolbar button needs a different anchor. The mouse is over the toolbar when you click it; anchoring there wouldn't help you inspect the document.
Our zoom buttons use the center of the current selection when that center is visible. Otherwise, they use the center of the editing viewport. That lets you select a small label, press the plus button a few times, and keep the label in roughly the same place.
The same coordinate calculation works for each input. What changes is the anchor:
| Input | Anchor |
|---|---|
| Wheel zoom | Cursor position |
| Two-finger pinch | Point initially beneath the fingers' midpoint |
| Toolbar zoom | Visible selection center, otherwise viewport center |
For a pinch, we save the initial distance between the fingers and the starting zoom. The new zoom is the starting zoom multiplied by the ratio of the current distance to the initial distance.
We also track the midpoint as the fingers move. The document point saved at the start follows that moving midpoint. This lets a pinch zoom and pan together, which feels much closer to moving a piece of paper under your fingers.
There are limits to how precisely an anchor can stay fixed. Scroll containers have boundaries. Zoom out until the document fits inside the viewport and the browser may have no remaining scroll range to compensate. At that point, the layout's centering rules take over.
It's also worth checking that every edge remains reachable when zoomed in. Centering an oversized document inside a fixed-width flex row can leave content overflowing on the left where you can't scroll to it. Our row grows with the document, and auto margins center the frame when there's spare space.
Zoom affects interaction distances too.
Our editor snaps blocks to alignment guides within an eight-pixel screen distance. If that were eight document pixels, the visible snapping distance would shrink to two pixels at 25% zoom and grow to sixteen at 200%.
To keep the visible distance consistent, the threshold passed to the document-coordinate calculations is:
const snapThreshold = 8 / zoom; At 25%, that's 32 document pixels. At 200%, it's four. Both cover eight pixels on screen.
The same question comes up with drag handles, tap tolerance, and selection outlines: should this measurement grow with the document, or stay comfortable to use on screen?
One of our browser tests checks zoom by recording the document point beneath the fingers, expressed as a fraction of the document's width and height. After pinching, those fractions must remain within one percentage point of their starting values.
That checks something useful about the interaction. A zoom readout changing from 50% to 100% would pass even if the text disappeared off screen.
For a quick manual check, put the cursor over the last digit of a price and zoom in a few times. Then select the price and try the toolbar buttons. You should be able to keep looking at the digit you started with, without needing another gesture to find it.
