RoadmapDay 60 / 80
React (Web)Month 3 · Week 12

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.

Mark this day complete

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

Get cursor position relative to the canvas container
Convert to a point in CONTENT space using the current transform
Apply the new zoom level
Recompute translate so that content point lands back under the cursor

this is what makes zoom feel "anchored"

Build It

Code Examples

Pan + zoom-to-cursor canvas

jsx
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

  1. 1Add pinch-to-zoom support for touch devices using two-finger touch events, reusing the same content-space math.
  2. 2Add a "reset view" / "fit to content" button that computes the transform needed to fit all child elements in view.
  3. 3Add a minimap in a corner showing the current viewport rectangle relative to all canvas content.