Day 60: Build: Infinite Canvas / Image Viewer with Zoom & Pan
Implement zoom-to-cursor and drag-to-pan with a single 2D transform matrix — the technique behind Figma/Miro-style infinite canvases.
Study
Concepts
One transform: translate + scale, applied together
The entire canvas content sits inside one container transformed with `translate(x, y) scale(zoom)` — panning updates x/y, zooming updates zoom, and both are just CSS transform values, letting the GPU handle the actual rendering cheaply instead of you recomputing every child element's position manually.
The subtlety that trips people up: zooming must keep the point UNDER THE CURSOR visually fixed, not zoom around the canvas origin — that requires adjusting the translate (x, y) alongside the scale change, using the cursor's position relative to the canvas at the moment of the zoom event.
See It
Visualizations
Visualization
Zoom-to-cursor math
this is what makes zoom feel "anchored"
Build It
Code Examples
Pan + zoom-to-cursor canvas
function InfiniteCanvas({ children }) {
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const containerRef = useRef(null);
const dragState = useRef(null);
function handleWheel(e) {
e.preventDefault();
const rect = containerRef.current.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
const newScale = Math.min(4, Math.max(0.2, transform.scale * zoomFactor));
// Point under the cursor, in CONTENT space, before the zoom changes
const contentX = (cursorX - transform.x) / transform.scale;
const contentY = (cursorY - transform.y) / transform.scale;
// Recompute translate so that same content point stays under the cursor
setTransform({
scale: newScale,
x: cursorX - contentX * newScale,
y: cursorY - contentY * newScale,
});
}
function handleMouseDown(e) {
dragState.current = { startX: e.clientX, startY: e.clientY, origin: transform };
}
function handleMouseMove(e) {
if (!dragState.current) return;
const { startX, startY, origin } = dragState.current;
setTransform((t) => ({
...t,
x: origin.x + (e.clientX - startX),
y: origin.y + (e.clientY - startY),
}));
}
function handleMouseUp() { dragState.current = null; }
return (
<div
ref={containerRef}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ overflow: 'hidden', width: '100%', height: '100%', cursor: 'grab' }}
>
<div
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: '0 0',
willChange: 'transform', // hints the browser to use GPU compositing
}}
>
{children}
</div>
</div>
);
}Remember
Key Takeaways
- One combined translate+scale transform on a single container is cheaper and simpler than repositioning every child manually.
- Zoom-to-cursor requires converting the cursor position to content space BEFORE changing scale, then solving translate to keep that point fixed.
- willChange: transform hints the browser to promote the layer for GPU compositing, keeping pan/zoom smooth.
- Clamp zoom scale to a sane min/max range — unbounded zoom breaks both usability and (at extremes) floating point precision.
- This exact translate+scale-under-cursor technique is the foundation of Figma, Miro, and any infinite-canvas product.
Do It
Practice
- 1Add pinch-to-zoom support for touch devices using two-finger touch events, reusing the same content-space math.
- 2Add a "reset view" / "fit to content" button that computes the transform needed to fit all child elements in view.
- 3Add a minimap in a corner showing the current viewport rectangle relative to all canvas content.