Pan and zoom surface hook
A React hook that adds wheel zoom around the cursor, drag panning and a CSS transform to any large canvas or diagram.
React / Next.jsHooksreactpanzoomcanvas
Live preview
Code
import { useEffect, useRef, useState, type CSSProperties } from 'react';
type View = { x: number; y: number; k: number };
export function usePanZoom(min = 0.2, max = 5) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [view, setView] = useState<View>({ x: 0, y: 0, k: 1 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// Native listener: React's onWheel is passive and cannot stop page scroll.
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
setView((v) => {
const k = Math.min(max, Math.max(min, v.k * Math.exp(-e.deltaY * 0.0015)));
const wx = (px - v.x) / v.k; // world point under the cursor
const wy = (py - v.y) / v.k;
return { k, x: px - wx * k, y: py - wy * k }; // keep it under the cursor
});
};
let last: { x: number; y: number } | null = null;
const down = (e: PointerEvent) => {
last = { x: e.clientX, y: e.clientY };
el.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent) => {
if (!last) return;
const dx = e.clientX - last.x;
const dy = e.clientY - last.y;
last = { x: e.clientX, y: e.clientY };
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
};
const up = () => { last = null; };
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('pointerdown', down);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
};
}, [min, max]);
const style: CSSProperties = {
transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`,
transformOrigin: '0 0',
};
return { containerRef, style, view, reset: () => setView({ x: 0, y: 0, k: 1 }) };
}
How it works
What it solves
Whiteboards, maps and diagram editors need an infinite surface the user can scroll around and zoom. The tricky part is zooming toward the pointer, not toward the corner; this hook gets that maths right and exposes a single transform string.
How it works
The view is { x, y, k }: a translation plus a scale. A screen point p maps to a world point with (p - offset) / k. When the wheel turns, the hook computes the world point under the cursor, applies the new scale, then solves for the new offset so that same world point stays under the cursor: x = px - worldX * k2. Scale is clamped between a minimum and maximum so the surface cannot vanish.
Wheel listeners must be non-passive to call preventDefault and stop the page scrolling. React attaches wheel handlers as passive, so the hook adds a native listener in an effect instead. Dragging uses pointer events with setPointerCapture, which keeps receiving moves even if the pointer leaves the element, and works for mouse, touch and pen.
The style it returns sets transform with translate then scale, and transformOrigin: '0 0' so the maths matches the CSS.
Adapting it
- Add a
resetthat returns to{0, 0, 1}. - Require the space key for panning if drag is used for drawing.
- Convert pointer positions to world coordinates with the same formula when placing objects.
Pitfalls
- Set
touch-action: noneon the container or touch devices scroll the page. - Very large scales blur raster content; zoom limits help.
How to use it
- Save as
hooks/usePanZoom.ts. const { containerRef, style } = usePanZoom().- Attach
ref={containerRef}to a clipped wrapper (overflow-hidden,touch-action: none). - Put the content in a child with
style={style}.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.