EmpireUI
Get Pro
← Blog9 min read#infinite canvas#react#pan zoom

Infinite Canvas in React: Pan, Zoom and Node-Based UI

Build a production-ready infinite canvas in React with pan, zoom, and node-based UI. Covers pointer events, SVG transforms, and library trade-offs.

Developer coding a node-based diagram on a wide monitor screen

What an Infinite Canvas Actually Is

An infinite canvas is a viewport with no hard edges - you pan across it with drag gestures, zoom in and out with a scroll wheel or pinch, and place nodes, shapes, or arbitrary React components at any coordinate. Figma, Miro, Excalidraw, tldraw. They all run on this mental model.

The trick is that nothing is actually infinite. What you've got is a single <div> (or SVG group) that carries a CSS transform: translate(x, y) scale(k) derived from some state. The content inside is just DOM elements positioned with position: absolute and left/top coordinates in *canvas space*. The browser only renders what's on screen, so you get apparent infinity without paying for it.

Honestly, the mental model flip that makes this click is separating *screen space* from *canvas space*. A pointer event gives you screen coordinates. To place a node where the user clicked, you have to invert the current transform - subtract the pan offset and divide by the scale. Miss that step and everything feels broken immediately.

Node-based UIs add one more layer: edges (connections) between nodes. Those are usually SVG <path> or <line> elements drawn in canvas space too, so they move with the pan/zoom for free. Quick aside: if you try to draw edges in screen space and project them manually, you'll regret it about 20 minutes in.

Core State: Pan, Zoom, and the Transform Matrix

You only need three numbers to describe the viewport: translateX, translateY, and scale. Everything else derives from them. In React that's a useReducer or a useRef if you want to skip re-renders on every pointer-move frame.

type ViewState = { x: number; y: number; scale: number };

const MIN_SCALE = 0.1;
const MAX_SCALE = 4;

function clamp(val: number, min: number, max: number) {
  return Math.min(max, Math.max(min, val));
}

// Convert screen coords → canvas coords
function screenToCanvas(
  sx: number,
  sy: number,
  view: ViewState
): { x: number; y: number } {
  return {
    x: (sx - view.x) / view.scale,
    y: (sy - view.y) / view.scale,
  };
}

Worth noting: storing the view state in a useRef and doing direct DOM mutations with element.style.transform instead of re-rendering through React state is the performance move for this. Re-rendering 200 nodes on every pointermove event will murder your frame rate. Read the ref during pointer events, mutate the DOM transform, and only sync back to React state when the gesture ends.

Zoom toward cursor is the interaction that trips most people up. The naive approach - just multiplying scale - zooms toward the origin (0,0) instead of the pointer position. The fix: before scaling, compute where the pointer is in canvas space, then after scaling compute where that same canvas point *would* end up in screen space, and adjust translate to compensate. ``tsx function zoomAtPoint( view: ViewState, screenX: number, screenY: number, delta: number ): ViewState { const newScale = clamp(view.scale * (1 - delta * 0.001), MIN_SCALE, MAX_SCALE); const ratio = newScale / view.scale; return { scale: newScale, x: screenX - ratio * (screenX - view.x), y: screenY - ratio * (screenY - view.y), }; } ``

That 4-line function is the most important thing in this article. Get it wrong and your users will fight your canvas every single time they try to zoom into a specific region.

Handling Pointer Events Without Going Insane

Use the Pointer Events API, not mouse events. onPointerDown, onPointerMove, onPointerUp. They unify mouse, touch, and stylus input with one API and support setPointerCapture so you don't lose drag state when the cursor leaves the canvas element. This was already the right call in 2022 - in 2026 there's no excuse.

For panning, setPointerCapture(e.pointerId) on pointerdown is key. It routes all subsequent pointer events to that element even if the pointer leaves the window. Without it, dragging quickly and moving the cursor out of the canvas boundary drops the event and your pan locks up mid-drag. One of those bugs that feels like a framework problem but is a missing one-liner.

const canvasRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });

function onPointerDown(e: React.PointerEvent) {
  if (e.button !== 0) return; // left button / primary touch only
  e.currentTarget.setPointerCapture(e.pointerId);
  dragging.current = true;
  lastPos.current = { x: e.clientX, y: e.clientY };
}

function onPointerMove(e: React.PointerEvent) {
  if (!dragging.current) return;
  const dx = e.clientX - lastPos.current.x;
  const dy = e.clientY - lastPos.current.y;
  lastPos.current = { x: e.clientX, y: e.clientY };
  // mutate viewRef and update DOM directly here
}

function onPointerUp() {
  dragging.current = false;
}

For two-finger pinch on touch devices you'll need to track two pointers simultaneously and compute the distance delta between them each frame. That's the scale gesture. It's maybe 40 lines of code and absolutely worth writing yourself before reaching for a gesture library - you'll understand your canvas far better for having done it.

In practice, keyboard shortcuts matter more than you'd think. Space + drag for pan (Figma-style), Ctrl + scroll for zoom, =/- for zoom steps, 0 to reset. Wire these up via useEffect with window.addEventListener('keydown', ...) rather than React's onKeyDown on the canvas element - you want them to work even when a node input is focused.

Building the Node Layer

Nodes are React components positioned in canvas space. The containing element has position: relative with the canvas transform applied; each node has position: absolute; left: {node.x}px; top: {node.y}px. That's it. You don't need to compute screen positions for rendering - the CSS transform does that math for you.

Node dragging is a separate concern from canvas panning. You need to distinguish: pointer down on a node = node drag; pointer down on empty canvas = pan. The cleanest pattern is stopPropagation() on the node's onPointerDown so the canvas never sees it, and handle the drag entirely in the node component using the same setPointerCapture pattern. ``tsx function Node({ node, onMove }: NodeProps) { const dragging = useRef(false); const startPos = useRef({ px: 0, py: 0, nx: 0, ny: 0 }); return ( <div style={{ position: 'absolute', left: node.x, top: node.y, cursor: 'grab', }} onPointerDown={(e) => { e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); dragging.current = true; startPos.current = { px: e.clientX, py: e.clientY, nx: node.x, ny: node.y }; }} onPointerMove={(e) => { if (!dragging.current) return; const { px, py, nx, ny } = startPos.current; onMove( node.id, nx + (e.clientX - px) / viewScale, // divide by scale! ny + (e.clientY - py) / viewScale ); }} onPointerUp={() => { dragging.current = false; }} > {node.content} </div> ); } ``

That divide-by-viewScale on the move delta is easy to miss. Without it, nodes move faster than your cursor when zoomed out and slower when zoomed in. Super disorienting. Pass the current scale down as a prop or read it from context.

Selection is next. A common approach: a Set<string> of selected node IDs in state, toggled with click (add/remove) and Shift+click (multi-select). Box selection (drag a rectangle on the canvas) requires computing which nodes have bounding boxes that intersect the selection rect in canvas space - straightforward AABB intersection math.

Look, for the visual styling of your nodes you've got a lot of room to get creative. Glassmorphism cards, brutalist borders, dark terminals - whatever fits your product. If you're building a design tool or whiteboard, check out the glassmorphism components on Empire UI for cards that look great floating on a canvas background, or the box shadow generator to tune that lifted-node feel without guessing CSS values.

Drawing Edges Between Nodes

Edges connect nodes. They live in an SVG layer that's absolutely positioned to cover the entire canvas container (same size, same transform applied). That way edges and nodes share the same coordinate system - no projection needed.

A cubic Bézier curve from an output handle to an input handle is the standard. The control points are usually offset 150px horizontally from each handle's position: ``tsx function Edge({ from, to }: { from: XY; to: XY }) { const cx1 = from.x + 150; const cy1 = from.y; const cx2 = to.x - 150; const cy2 = to.y; const d = M${from.x},${from.y} C${cx1},${cy1} ${cx2},${cy2} ${to.x},${to.y}; return ( <path d={d} fill="none" stroke="#6366f1" strokeWidth={2} /> ); } ``

That 150px offset works fine at scale=1 but looks too spread at scale=0.1 and too tight at scale=4. You can multiply by a clamped scale factor, or just accept the slight imperfection - Figma doesn't bother adjusting it dynamically either. Worth noting: strokeWidth on the SVG path is in canvas space, so it scales visually with zoom, which is usually what you want (the edge looks the same thickness relative to nodes at any zoom level).

Hit detection on edges for click-to-select is the annoying part. A 2px stroke is nearly impossible to click precisely. The trick is rendering a second invisible <path> with strokeWidth={12} and stroke="transparent" pointerEvents="stroke" directly on top of the visible one. That 12px hit area feels precise to users but is actually forgiving enough to click reliably.

Library Trade-offs: Roll Your Own vs React Flow vs tldraw

If you're building a product feature (a workflow builder, a pipeline editor, a mindmap view) rather than a standalone whiteboard product, React Flow (now @xyflow/react, v12 as of 2025) is the pragmatic call. You get nodes, edges, minimap, background grid, and pan/zoom in about 50 lines. The API is well-designed, the docs are good, and the pro plugins cover selection, layout engines, and collaboration helpers. The bundle cost is around 180 KB minified, which is the main downside.

tldraw is phenomenal if you're building a whiteboard-like experience with freehand drawing, shapes, and text. But it's opinionated and has a large bundle. You're either all-in on the tldraw document model or you're fighting it.

Rolling your own - which this article is about - makes sense when: you need canvas performance for 5,000+ nodes and want full control over rendering; you're already at bundle budget limits; or your node UI is complex enough that you'd be fighting an abstraction anyway. It's also genuinely fun to build, and you'll understand CSS transforms at a level that makes you better at every other UI work you do.

That said, don't be a hero on a deadline. Shipping on time with React Flow beats spending three weeks on a custom canvas that doesn't handle edge cases. The best React UI libraries in 2026 article has a broader breakdown of when to pick off-the-shelf vs roll your own - worth a read before you decide.

One more thing - performance. If you hit 1,000+ nodes and start feeling frame drops, look at React.memo on your node components first (they re-render on every pan without it), then consider moving pan/zoom to a ref + direct DOM mutation (described earlier), and finally investigate canvas-based rendering with <canvas> for nodes if you truly need 10,000+. Most apps never need that third option.

Fit-to-View, Minimap, and Other Details That Matter

Fit-to-view centers all nodes in the viewport. The algorithm: find the bounding box of all nodes (min/max x and y), compute the scale that fits it within the viewport with some padding, and compute the translate that centers it. Usually about 20 lines. This is the button users click when they open a saved diagram and everything is off-screen.

function fitToView(nodes: Node[], vpWidth: number, vpHeight: number, padding = 40): ViewState {
  const xs = nodes.map(n => n.x);
  const ys = nodes.map(n => n.y);
  const minX = Math.min(...xs);
  const maxX = Math.max(...xs) + 200; // node width estimate
  const minY = Math.min(...ys);
  const maxY = Math.max(...ys) + 80;  // node height estimate
  const contentW = maxX - minX;
  const contentH = maxY - minY;
  const scale = clamp(
    Math.min((vpWidth - padding * 2) / contentW, (vpHeight - padding * 2) / contentH),
    MIN_SCALE, MAX_SCALE
  );
  return {
    scale,
    x: (vpWidth - contentW * scale) / 2 - minX * scale,
    y: (vpHeight - contentH * scale) / 2 - minY * scale,
  };
}

A minimap is a small <canvas> element in the corner that draws colored rectangles representing node positions scaled down to fit. Clicking/dragging on the minimap pans the main canvas. It's a nice-to-have for large graphs - users need it when they have 50+ nodes and lose their place. Implementation is an afternoon of work.

Snapping to a grid on node drop is a single modulo operation: Math.round(x / gridSize) * gridSize. Use 8px or 16px grid. That's the grid size Figma and most design tools default to - 8px because it divides cleanly into common spacing scales. Optional but makes the output feel polished.

Last thing: persisting the graph. Nodes are plain objects { id, x, y, data } and edges are { id, source, target }. Serialize to JSON, store in localStorage for a quick prototype, or send to your backend. Keep the view state separate from the graph state - you probably don't want to restore the zoom level, just the node positions. If you're using something from the Empire UI component library for your node cards or UI chrome, the components are easy to embed inside node content - just drop them in as JSX and they render in canvas space like any other React component.

FAQ

What's the difference between screen space and canvas space in a pan/zoom canvas?

Screen space is pixel coordinates relative to the viewport - what e.clientX/Y gives you. Canvas space is the coordinate system your nodes live in, which shifts and scales as you pan and zoom. To convert: subtract the current translate offset then divide by scale.

Should I use React Flow or build a custom canvas?

React Flow (now @xyflow/react v12) is the right call for most product features - it handles 95% of cases with solid defaults. Roll your own when you need sub-millisecond pan/zoom on 5,000+ nodes or you need total control over rendering.

Why does my node drag feel off when zoomed in or out?

You're probably not dividing the pointer delta by the current scale. Node position updates need to be in canvas space, not screen space. Divide e.clientX - startClientX by viewScale before applying it to the node's canvas coordinates.

How do I make zoom target the cursor position instead of the origin?

Compute where the cursor is in canvas space before scaling, then after scaling adjust the translate so that same canvas point stays under the cursor. The zoomAtPoint function in this article covers the exact math.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Pagination in React: Controlled, Infinite Scroll, URL-BasedHTML Canvas Animations in React: Particles, Noise Fields, MoreWebGL Background Effects Without Three.js: Raw Shaders in ReactSpatial UI Design in 2026: Vision Pro, Depth and the Glass Era