A macOS-style zoom loupe in SVG
A recipe to make a zoom loupe that follows the pointer, using nested SVGs, viewBox, and <use>
This is my blog. I can write whatever I want. Not everything has to be a well-researched deep dive into some obscure corner of React. I can write about everyday things. Small recipes for neat patterns I find in my own code.
Someone who does small recipes very well is Sam Selikoff.
A few years ago, I saw this video of his, a recipe on making a responsive line chart.
At the time I did not really understand viewBox.
I knew what it was for but I just never looked into it deeply.
I copied the viewBox from whatever D3 example I used as a starting point and didn't touch it.
Sam's video taught me that an <svg> element can contain a second <svg> element, and that the nested element gets its own viewBox.
That knowledge composted for a while in my head and then it became the soil for the recipe in this post.
macOS has a zoom style that follows the pointer. Its official name is Picture-in-Picture.
You find it in System Settings → Accessibility → Zoom. Press ⌥ ⌘ 8 to activate it. A panel follows the pointer and shows a magnified view of the screen. I use it all the time.
A while ago, I wanted something like this for zooming in on a chart suffering from overplotting. Four SVG features do all the work. The demo plots the Palmer penguins dataset, with a color and a symbol for each species. Drag the loupe in the demo below. You can also focus the loupe and move it with the arrow keys.
A few lesser-known SVG features make this work:
<use>makes a live copy of an element.- A nested
<svg>shows a part of the drawing. Yes, an<svg>element can contain a second<svg>element 1. - The
viewBoxattribute selects that part. <clipPath>cuts the loupe to a shape, like a circle.
Put the chart content in a group. Give the group an id.
<svg viewBox="0 0 500 400"> <g id="chart"> <line x1="50" y1="350" x2="450" y2="350" stroke="#999" /> <line x1="50" y1="350" x2="50" y2="50" stroke="#999" /> <circle cx="200" cy="180" r="5" fill="steelblue" /> <!-- more data points --> </g></svg>
The axes are inside the group. The loupe then magnifies the axes together with the points.
The id must be unique on the page.
If the chart appears twice, give each copy its own id.
You can use React's useId hook for that.
The <use> element refers to the group.
<use href="#chart" />
The copy is live.
The <use> element does not paste a second version of the markup into the document.
The browser renders the referenced element again, at the place of the <use> element.
The clone lives in a hidden shadow tree 2.
Open the dev tools and you can find a #shadow-root inside the <use> element.
Chrome hides it unless you enable "Show user agent shadow DOM" in the DevTools settings.
When the chart changes, the copy changes with it. Which is nice because that means you do not need to keep two copies in sync. The loupe always magnifies whatever the chart shows right now.
Put a second <svg> element in the chart.
Put the <use> element inside it.
<svg x="-55" y="-55" width="110" height="110" viewBox="142.5 207.5 55 55"> <use href="#chart" /></svg>
The viewBox of the nested element selects the visible area 3.
Hence it also controls the zoom.
The zoom factor is the element width divided by the viewBox width.
Here the element is 110 units wide and the viewBox is 55 units wide.
The result is a 2× zoom.
Note that the loupe shows less area than it covers. At a 2× zoom, the visible region is half of the lens diameter. A higher zoom shows a smaller region, so points under the rim of the lens stay out of view.
Define a <circle> in a <clipPath>.
Apply it to the group around the nested <svg>.
<defs> <clipPath id="loupe-clip"> <circle cx="0" cy="0" r="55" /> </clipPath></defs><circle r="55" fill="var(--color-bg)" /><g clip-path="url(#loupe-clip)"> <!-- nested svg goes here --></g>
The loupe also needs a backdrop.
Without one, the original chart shows through the magnified copy which looks messy.
Put a circle under the nested <svg> and fill it with the page background color.
A background on the outer <svg> does not fix this.
SVG has no z-index. Elements paint in document order 4.
The chart paints on top of such a background, so its points would still show through the lens.
The backdrop must paint after the chart and before the magnified copy.
That is why it lives inside the loupe group.
The pointer moves in screen pixels but the chart uses SVG units. The two are not equal, because the browser scales the SVG to its rendered size.
The getScreenCTM() method returns the conversion factors.
ctm.a is the number of screen pixels for one horizontal SVG unit.
ctm.d is the same number for the vertical direction.
You need to divide the pointer movement by these factors to get the movement in SVG units.
getScreenCTM() is a bit complicated, I'll leave a detailed explanation of it for another blog post.
The demo uses the useMove hook from React Aria.
The hook reports movement deltas for the mouse, touch, and the keyboard with one callback.
import { useRef, useState } from "react";import { useMove } from "react-aria";const RADIUS = 55;const ZOOM = 2;// A loose spread plus a tight cluster around (200, 180)const POINTS: Array<[number, number]> = [ [120, 280], [180, 220], [195, 185], [200, 180], [205, 175], [198, 190], [210, 185], [320, 120], [380, 90],];function ZoomLoupe() { const svgRef = useRef<SVGSVGElement>(null); const [pos, setPos] = useState({ x: 200, y: 180 }); const { moveProps } = useMove({ onMove(e) { setPos((p) => { // Convert screen pixels to SVG units const ctm = svgRef.current?.getScreenCTM(); if (!ctm) return p; return { x: p.x + e.deltaX / ctm.a, y: p.y + e.deltaY / ctm.d, }; }); }, }); // The viewBox centers on the loupe position const vbSize = (RADIUS * 2) / ZOOM; const vbX = pos.x - vbSize / 2; const vbY = pos.y - vbSize / 2; return ( <svg ref={svgRef} viewBox="0 0 500 400"> <defs> <clipPath id="loupe-clip"> <circle cx="0" cy="0" r={RADIUS} /> </clipPath> </defs> <g id="chart"> <line x1="50" y1="350" x2="450" y2="350" stroke="#999" /> <line x1="50" y1="350" x2="50" y2="50" stroke="#999" /> {POINTS.map(([cx, cy], i) => ( <circle key={i} cx={cx} cy={cy} r="5" fill="steelblue" /> ))} </g> <g transform={`translate(${pos.x}, ${pos.y})`} style={{ cursor: "grab", touchAction: "none" }} tabIndex={0} {...moveProps} > {/* Hit area and backdrop: hides the unzoomed chart under the lens */} <circle r={RADIUS} fill="var(--color-bg)" /> <g clipPath="url(#loupe-clip)"> <svg x={-RADIUS} y={-RADIUS} width={RADIUS * 2} height={RADIUS * 2} viewBox={`${vbX} ${vbY} ${vbSize} ${vbSize}`} > <use href="#chart" /> </svg> </g> <circle r={RADIUS} fill="none" stroke="var(--gray-8)" strokeWidth="3" /> </g> </svg> );}
Each pointer event updates the viewBox of the nested <svg>.
The nested <svg> then shows the area under the loupe at a 2× zoom.
A note on performance
The browser renders a style change in up to three phases.
- Layout calculates where each element goes.
- Paint generates the drawing instructions for them.
- Composite turns those instructions into actual pixels. A change that triggers an early phase forces every later phase to run too, so earlier phases are more expensive.
viewBox is an attribute, and the only SVG attribute that triggers layout.
Change it and the browser reruns layout and repaints the SVG subtree on the main thread, on the CPU.
This happens on every update.
The CSS transform property is one of the few compositable properties.
The browser rasterizes the content into a layer once.
From then on, moving it is the GPU sliding a cached texture around.
Here, repainting is fine but good to be aware of.
For continuous motion, don't animate viewBox, instead keep it static and animate a CSS transform on a group inside the window instead.
I learned that viewBox cannot be composited from Nanda's great svg.guide course. Recommended!
This recipe is just one use of nested viewBoxes.
The same technique can be used for inset axes like in matplotlib, minimaps, and map insets.
Maybe I'll write small 'recipe' posts for those too.
- Understanding SVG Coordinate Systems and Transformations by Sara Soueidan
- Nesting SVGs by Sara Soueidan
-
↩
Including an ‘svg’ element inside SVG content creates a new SVG viewport into which all contained graphics are drawn; this implicitly establishes both a new viewport coordinate system and a new user coordinate system.
SVG2 Spec: 8.8 Establishing a new SVG viewport -
↩
Elements in the shadow tree are rendered as if the ‘use’ element was a container and they were its children. However, the SVG Document Object Model (DOM) only contains the ‘use’ element and its attributes. The SVG DOM does not include the element instances as children of the ‘use’ element. [...] The user agent must ensure that all mutations to the referenced document subtree are reflected in the shadow tree. This includes changes to elements, attributes, and text and other nodes.
SVG2 Spec: 5.6.1 The use-element shadow tree -
↩
The value of the ‘viewBox’ attribute is a list of four numbers min-x, min-y, width and height, separated by whitespace and/or a comma, that specify a rectangle in user space that should be mapped to the bounds of the SVG viewport established by the given element.
SVG2 Spec: 8.6 The ‘viewBox’ attribute -
↩
Elements in an SVG document fragment have an implicit drawing order, with the first elements in the SVG document fragment getting "painted" first. Subsequent elements are painted on top of previously painted elements.
SVG1.1 Spec: 3.3 Rendering Order