Nativ ui
Documentation

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:

  • zoom is your user zoom factor (0.1–5)
  • calculatedScale is no longer multiplied into transform — apply it only to initial centering logic if you still want auto-fit behavior
  • Remove flex justify-center p-8 from 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.current
  • panOld = 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-center and p-8 from 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 panOffset and zoom on 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.