Zoom to Cursor
Figma-style zoom-to-cursor implementation with pan, scale, and dot grid sync.
Overview
This playbook covers how to implement a Figma-style zoom-to-cursor in a canvas/preview component. The key principles are:
- Make the pan layer the single source of truth for both pan and scale
- Remove layout side-effects (
justify-center,padding) from the transformed element - Compute pan so that the world point under the cursor stays fixed in screen space
1. Layout (simplify transform space)
Move scale onto the pan layer and let the "frame wrapper" size be unscaled. This keeps world coordinates simple.
// Layer 1: Container
<div
ref={containerRef}
className="relative w-full h-full overflow-hidden"
style={{
backgroundImage: `radial-gradient(circle, dot ${dotSize}px, transparent ${dotSize}px)`,
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${panOffset.x}px ${panOffset.y}px`,
}}
>
{/* Layer 2: Pan + Zoom layer */}
<div
className="absolute inset-0"
style={{
transform: `translate3d(${panOffset.x}px, ${panOffset.y}px, 0) scale(${zoom})`,
transformOrigin: 'top left',
}}
>
{/* Layer 3: Frame wrapper */}
<div style={{ width: deviceWidth }}>
{/* Layer 4: Actual frame (no scale here) */}
<div style={{ width: deviceWidth }}>
{/* Page content */}
</div>
</div>
</div>
</div>Notes:
zoomis your user zoom factor (0.1–5)calculatedScaleis no longer multiplied intotransform— apply it only to initial centering logic if you still want auto-fit behavior- Remove
flex justify-center p-8from the pan layer and do any centering as one-time pan initialization
Initial centering
Run once on mount or on container/device change:
const fitScale = Math.min((containerWidth - 64) / deviceWidth, 1);
setZoom(fitScale);
const initialX = (containerWidth - deviceWidth * fitScale) / 2;
const initialY = 32;
setPanOffset({ x: initialX, y: initialY });Treat zoom and panOffset purely as transform parameters, not layout.
2. Zoom-to-cursor math
We want the point under the cursor to keep the same screen position when zoom changes.
Screen position: S = (sx, sy) (cursor in container coords)
Pan/scale transform: S = pan + zoom × W where W is the world coordinate
Before zoom:
zoomOld = zoomRef.currentpanOld = panOffset
World coordinate of cursor: W = (S - panOld) / zoomOld
After zoom to zoomNew, to keep same S:
panNew = S - zoomNew × W
= S - (zoomNew / zoomOld) × (S - panOld)
Implementation
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
// Ctrl/Meta = zoom
if (e.ctrlKey || e.metaKey) {
const container = containerRef.current;
if (!container) return;
const normalizedDelta =
e.deltaY > 0 ? Math.min(e.deltaY, 50) : Math.max(e.deltaY, -50);
const zoomFactor = Math.pow(0.9985, normalizedDelta);
const zoomOld = zoomRef.current;
const zoomNew = clamp(zoomOld * zoomFactor, 0.1, 5);
if (zoomNew === zoomOld) return;
// Cursor in container (screen) coords
const rect = container.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const scaleRatio = zoomNew / zoomOld;
setPanOffset(prev => ({
x: sx - scaleRatio * (sx - prev.x),
y: sy - scaleRatio * (sy - prev.y),
}));
setZoom(zoomNew);
zoomRef.current = zoomNew;
return;
}
// No Ctrl/Meta: pan
setPanOffset(prev => ({
x: prev.x - e.deltaX,
y: prev.y - e.deltaY,
}));
};This formula is invariant to padding, centering, etc., as long as all content is under the same translate + scale transform and sx, sy are in that container's coordinate system.
3. Dot grid background sync
Since the world transform is translate(panOffset) scale(zoom) with origin top-left, use the same values to drive the dot grid:
<div
ref={containerRef}
className="relative w-full h-full overflow-hidden"
style={{
backgroundImage: `radial-gradient(circle, dot ${dotSize}px, transparent ${dotSize}px)`,
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${panOffset.x}px ${panOffset.y}px`,
}}
>If you want the dots to be "centered" at world origin (0,0) instead of top-left, add a modulo shift:
backgroundPosition: `${panOffset.x % (20 * zoom)}px ${panOffset.y % (20 * zoom)}px`For a Figma-style infinite grid, simply using panOffset is usually enough.
4. Remove justify-center
For this transform math to work cleanly:
- Remove
justify-centerandp-8from the transformed pan layer - Apply those only to static layout parents, or compute initial centering as a one-time pan adjustment
- Keep all dynamic view changes (zoom, pan, zoom-to-cursor, fit-to-screen) expressed only in terms of
panOffsetandzoomon a single transform
This keeps your coordinate system affine and makes hit-testing, selection boxes, and future features (marquee zoom, double-click focus, etc.) much easier.