"use client";
import * as React from "react";
import { animate, motion, useMotionTemplate, useMotionValue, useTransform, type AnimationPlaybackControls } from "motion/react";
export interface PanZoomTransform {
x: number;
y: number;
zoom: number;
}
export interface PanZoomCanvasHandle {
/** Zoom to an absolute level around a point in viewport px (defaults to the viewport centre). */
zoomTo: (zoom: number, origin?: { x: number; y: number }) => void;
zoomIn: () => void;
zoomOut: () => void;
/** Fit every child into the viewport (with `fitPadding`). */
fitToContent: () => void;
/** Return to the initial view (fitted when `initialFit`, otherwise 100% at the origin). */
reset: () => void;
getTransform: () => PanZoomTransform;
}
export interface PanZoomCanvasProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"> {
/** Content placed in canvas coordinates — use `CanvasItem` or absolutely positioned children. */
children?: React.ReactNode;
minZoom?: number;
maxZoom?: number;
/** Multiplier for the zoom buttons and the +/− keys. */
zoomStep?: number;
/** What the mouse wheel does: zoom around the cursor, or pan (pinch / Ctrl + wheel still zooms). */
wheelMode?: "zoom" | "pan";
/** Keep gliding after a drag is released. */
inertia?: boolean;
/** Show the dot grid. */
showGrid?: boolean;
/** Grid spacing at 100% zoom, in canvas px. */
gridSize?: number;
/** Show the zoom controls. */
showControls?: boolean;
/** Fit the content into view on mount (and on `reset`). */
initialFit?: boolean;
/** Padding kept around the content when fitting, in px. */
fitPadding?: number;
/** Accent for the focus ring and the zoom readout. */
accent?: string;
/** Accessible name of the canvas region. */
label?: string;
/** Called on every transform change (pan, zoom, animation frames). */
onTransformChange?: (transform: PanZoomTransform) => void;
}
export interface CanvasItemProps extends React.HTMLAttributes<HTMLDivElement> {
/** Left edge in canvas px. */
x: number;
/** Top edge in canvas px. */
y: number;
}
/** Places its children at canvas coordinates inside a `PanZoomCanvas`. */
export function CanvasItem({ x, y, ...props }: CanvasItemProps) {
// The outer box only positions (max-content, so text never wraps against the zero-size layer);
// className, style and the rest go to the inner element.
return (
<div className="absolute w-max" style={{ left: x, top: y }}>
<div {...props} />
</div>
);
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const VIEW_SPRING = { type: "spring", stiffness: 260, damping: 34, mass: 0.9 } as const;
const GLIDE_TIME = 0.3; // s, decay time constant of the inertial glide
const GLIDE_POWER = 0.8; // fraction of the release velocity carried into the glide
const PAN_STEP = 48; // px per arrow key press (×4 with Shift)
/** Elements that keep their own pointer behaviour instead of starting a pan. */
const INTERACTIVE = "button, a[href], input, select, textarea, [contenteditable='true'], [data-no-pan]";
/**
* An infinite-feeling canvas viewport. Drag to pan (with inertia), wheel or pinch to zoom around
* the pointer, zoom controls with a live readout, and keyboard support when focused (arrows pan,
* + / − zoom, 0 resets, F fits). Children are placed in canvas coordinates. Exposes an imperative
* handle: zoomTo, zoomIn, zoomOut, fitToContent, reset, getTransform.
*/
export const PanZoomCanvas = React.forwardRef<PanZoomCanvasHandle, PanZoomCanvasProps>(function PanZoomCanvas(
{
children,
minZoom = 0.25,
maxZoom = 3,
zoomStep = 1.25,
wheelMode = "zoom",
inertia = true,
showGrid = true,
gridSize = 24,
showControls = true,
initialFit = true,
fitPadding = 48,
accent = "#FF7448",
label = "Canvas",
onTransformChange,
className = "",
style,
onKeyDown,
...props
},
ref,
) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const viewportRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const readoutRef = React.useRef<HTMLSpanElement>(null);
const running = React.useRef<AnimationPlaybackControls[]>([]);
/** Where the running spring is heading, so repeated zoom steps compound from the target. */
const target = React.useRef<PanZoomTransform | null>(null);
const glideFrame = React.useRef(0);
const pointers = React.useRef(new Map<number, { x: number; y: number }>());
const gesture = React.useRef({ lastX: 0, lastY: 0, distance: 0, samples: [] as { x: number; y: number; t: number }[] });
const [dragging, setDragging] = React.useState(false);
const zoomOutRef = React.useRef<HTMLButtonElement>(null);
const zoomInRef = React.useRef<HTMLButtonElement>(null);
const [announcement, setAnnouncement] = React.useState("");
const x = useMotionValue(0);
const y = useMotionValue(0);
const zoom = useMotionValue(1);
// Dot grid that pans and scales with the content.
const gridStep = useTransform(() => `${Math.round(gridSize * zoom.get() * 100) / 100}px`);
const backgroundSize = useMotionTemplate`${gridStep} ${gridStep}`;
const backgroundPosition = useMotionTemplate`${x}px ${y}px`;
/* ----------------------------- transform helpers ----------------------------- */
const stop = () => {
cancelAnimationFrame(glideFrame.current);
for (const a of running.current) a.stop();
running.current = [];
target.current = null;
};
/** The settled view: the spring's target while one runs, else the current values. */
const base = (): PanZoomTransform => target.current ?? { x: x.get(), y: y.get(), zoom: zoom.get() };
const viewportSize = () => {
const el = viewportRef.current;
return { width: el?.clientWidth ?? 0, height: el?.clientHeight ?? 0 };
};
/** Viewport-local pointer coordinates, compensating for scaled ancestors. */
const localPoint = (clientX: number, clientY: number) => {
const el = viewportRef.current;
if (!el) return { x: 0, y: 0 };
const rect = el.getBoundingClientRect();
const scale = el.offsetWidth ? rect.width / el.offsetWidth || 1 : 1;
return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };
};
/** Applies a transform immediately (gestures) or on a spring (buttons, keys, fit). */
const apply = (next: PanZoomTransform, animated: boolean) => {
stop();
if (!animated || reduceMotion) {
x.set(next.x);
y.set(next.y);
zoom.set(next.zoom);
return;
}
target.current = next;
const done = () => {
if (target.current === next) target.current = null;
};
running.current = [animate(x, next.x, VIEW_SPRING), animate(y, next.y, VIEW_SPRING), animate(zoom, next.zoom, { ...VIEW_SPRING, onComplete: done })];
};
/** The transform that zooms to `target` while keeping the viewport point `origin` fixed. */
const zoomAround = (level: number, origin: { x: number; y: number }, from: PanZoomTransform = { x: x.get(), y: y.get(), zoom: zoom.get() }): PanZoomTransform => {
const next = clamp(level, minZoom, maxZoom);
const k = next / from.zoom;
return { x: origin.x - (origin.x - from.x) * k, y: origin.y - (origin.y - from.y) * k, zoom: next };
};
const center = () => {
const { width, height } = viewportSize();
return { x: width / 2, y: height / 2 };
};
/** Bounding box of the children in canvas coordinates (offsets are unaffected by the transform). */
const contentBounds = () => {
const content = contentRef.current;
if (!content || content.children.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const child of Array.from(content.children)) {
if (!(child instanceof HTMLElement)) continue;
minX = Math.min(minX, child.offsetLeft);
minY = Math.min(minY, child.offsetTop);
maxX = Math.max(maxX, child.offsetLeft + child.offsetWidth);
maxY = Math.max(maxY, child.offsetTop + child.offsetHeight);
}
return Number.isFinite(minX) ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
};
const fitTransform = (): PanZoomTransform => {
const bounds = contentBounds();
const { width, height } = viewportSize();
if (!bounds || !width || !height) return { x: 0, y: 0, zoom: 1 };
const z = clamp(
Math.min((width - fitPadding * 2) / Math.max(1, bounds.width), (height - fitPadding * 2) / Math.max(1, bounds.height)),
minZoom,
Math.min(maxZoom, 1.5),
);
return { x: (width - bounds.width * z) / 2 - bounds.x * z, y: (height - bounds.height * z) / 2 - bounds.y * z, zoom: z };
};
const initialTransform = () => (initialFit ? fitTransform() : { x: 0, y: 0, zoom: 1 });
const percent = (z: number) => `${Math.round(z * 100)}%`;
const actions = {
zoomTo: (z: number, origin?: { x: number; y: number }) => {
const next = zoomAround(z, origin ?? center(), base());
apply(next, true);
setAnnouncement(`Zoom ${percent(next.zoom)}`);
},
zoomIn: () => actions.zoomTo(base().zoom * zoomStep),
zoomOut: () => actions.zoomTo(base().zoom / zoomStep),
fitToContent: () => {
const next = fitTransform();
apply(next, true);
setAnnouncement(`Fitted to content, zoom ${percent(next.zoom)}`);
},
reset: () => {
const next = initialTransform();
apply(next, true);
setAnnouncement(`View reset, zoom ${percent(next.zoom)}`);
},
panBy: (dx: number, dy: number) => {
const from = base();
apply({ x: from.x + dx, y: from.y + dy, zoom: from.zoom }, true);
},
};
React.useImperativeHandle(ref, () => ({
zoomTo: actions.zoomTo,
zoomIn: actions.zoomIn,
zoomOut: actions.zoomOut,
fitToContent: actions.fitToContent,
reset: actions.reset,
getTransform: () => ({ x: x.get(), y: y.get(), zoom: zoom.get() }),
}));
// Live readout and button states, written to the DOM so zooming never re-renders the tree.
const syncReadout = (z: number) => {
if (readoutRef.current) readoutRef.current.textContent = percent(z);
// aria-disabled (not disabled) so a focused button keeps focus when it reaches a limit.
zoomOutRef.current?.setAttribute("aria-disabled", String(z <= minZoom + 1e-3));
zoomInRef.current?.setAttribute("aria-disabled", String(z >= maxZoom - 1e-3));
};
const notify = () => onTransformChange?.({ x: x.get(), y: y.get(), zoom: zoom.get() });
const onZoomChange = React.useEffectEvent((z: number) => {
syncReadout(z);
notify();
});
const onPanChange = React.useEffectEvent(() => notify());
const initialise = React.useEffectEvent(() => {
const next = initialTransform();
x.set(next.x);
y.set(next.y);
zoom.set(next.zoom);
});
// Subscribe first, then set the initial view before paint so the content never jumps.
React.useLayoutEffect(() => {
const unsubscribe = [zoom.on("change", onZoomChange), x.on("change", onPanChange), y.on("change", onPanChange)];
initialise();
return () => unsubscribe.forEach((off) => off());
}, [x, y, zoom]);
// Clamp the current view when the limits change.
const clampToLimits = React.useEffectEvent(() => {
const z = zoom.get();
if (z < minZoom || z > maxZoom) apply(zoomAround(z, center()), false);
else syncReadout(z);
});
React.useEffect(() => clampToLimits(), [minZoom, maxZoom]);
/* ---------------------------------- wheel ---------------------------------- */
// A non-passive listener is required to keep the page from scrolling while zooming the canvas.
const onWheel = React.useEffectEvent((event: WheelEvent) => {
event.preventDefault();
stop();
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewportSize().height : 1;
const dx = event.deltaX * unit;
const dy = event.deltaY * unit;
// Trackpad pinch arrives as Ctrl + wheel.
if (wheelMode === "zoom" || event.ctrlKey || event.metaKey) {
const factor = Math.exp(-dy * (event.ctrlKey ? 0.01 : 0.0015));
apply(zoomAround(zoom.get() * factor, localPoint(event.clientX, event.clientY)), false);
} else {
apply({ x: x.get() - dx, y: y.get() - dy, zoom: zoom.get() }, false);
}
});
React.useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const handler = (event: WheelEvent) => onWheel(event);
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
// Stop any running glide or spring on unmount.
React.useEffect(() => {
const list = running;
const frame = glideFrame;
return () => {
cancelAnimationFrame(frame.current);
list.current.forEach((a) => a.stop());
};
}, []);
/* -------------------------------- pointers --------------------------------- */
const pinchInfo = () => {
const [a, b] = Array.from(pointers.current.values());
return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, distance: Math.hypot(a.x - b.x, a.y - b.y) };
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0 && event.button !== 1) return;
if ((event.target as Element).closest(INTERACTIVE)) return;
stop();
event.currentTarget.setPointerCapture(event.pointerId);
const p = localPoint(event.clientX, event.clientY);
pointers.current.set(event.pointerId, p);
const g = gesture.current;
if (pointers.current.size === 2) {
const { mid, distance } = pinchInfo();
g.lastX = mid.x;
g.lastY = mid.y;
g.distance = distance;
} else {
g.lastX = p.x;
g.lastY = p.y;
}
g.samples = [{ x: p.x, y: p.y, t: event.timeStamp }];
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(event.pointerId)) return;
const p = localPoint(event.clientX, event.clientY);
pointers.current.set(event.pointerId, p);
const g = gesture.current;
if (pointers.current.size >= 2) {
// Pinch: zoom around the midpoint and pan with it.
const { mid, distance } = pinchInfo();
const zoomed = zoomAround(zoom.get() * (g.distance ? distance / g.distance : 1), mid);
x.set(zoomed.x + mid.x - g.lastX);
y.set(zoomed.y + mid.y - g.lastY);
zoom.set(zoomed.zoom);
g.lastX = mid.x;
g.lastY = mid.y;
g.distance = distance;
g.samples = [];
return;
}
x.set(x.get() + p.x - g.lastX);
y.set(y.get() + p.y - g.lastY);
g.lastX = p.x;
g.lastY = p.y;
g.samples.push({ x: p.x, y: p.y, t: event.timeStamp });
if (g.samples.length > 6) g.samples.shift();
};
const endPointer = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.delete(event.pointerId)) return;
const g = gesture.current;
if (pointers.current.size === 1) {
// Pinch ended with one finger left: continue panning from it.
const [rest] = Array.from(pointers.current.values());
g.lastX = rest.x;
g.lastY = rest.y;
g.samples = [];
return;
}
if (pointers.current.size > 0) return;
setDragging(false);
if (!inertia || reduceMotion || g.samples.length < 2) return;
const first = g.samples[0];
const last = g.samples[g.samples.length - 1];
const dt = last.t - first.t;
// Ignore stale samples (pointer held still before release).
if (dt <= 0 || event.timeStamp - last.t > 80) return;
const vx = ((last.x - first.x) / dt) * 1000;
const vy = ((last.y - first.y) / dt) * 1000;
glide(vx * GLIDE_POWER, vy * GLIDE_POWER);
};
/** Exponential decay of the release velocity (px/s), one frame at a time. */
const glide = (vx: number, vy: number) => {
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(64, now - last) / 1000;
last = now;
const decay = Math.exp(-dt / GLIDE_TIME);
vx *= decay;
vy *= decay;
x.set(x.get() + vx * dt);
y.set(y.get() + vy * dt);
if (Math.hypot(vx, vy) > 10 && !document.hidden) glideFrame.current = requestAnimationFrame(step);
};
glideFrame.current = requestAnimationFrame(step);
};
/* --------------------------------- keyboard -------------------------------- */
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
// Only when the canvas itself is focused, so inputs inside keep their keys.
if (event.defaultPrevented || event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return;
const step = event.shiftKey ? PAN_STEP * 4 : PAN_STEP;
const keys: Record<string, () => void> = {
ArrowLeft: () => actions.panBy(step, 0),
ArrowRight: () => actions.panBy(-step, 0),
ArrowUp: () => actions.panBy(0, step),
ArrowDown: () => actions.panBy(0, -step),
"+": actions.zoomIn,
"=": actions.zoomIn,
"-": actions.zoomOut,
_: actions.zoomOut,
"0": actions.reset,
f: actions.fitToContent,
F: actions.fitToContent,
};
const run = keys[event.key];
if (!run) return;
event.preventDefault();
run();
};
const hintId = `${uid}-hint`;
const button =
"grid size-8 place-items-center rounded-md text-zinc-600 transition-colors outline-hidden hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pz-accent)] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:hover:bg-transparent dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100";
return (
<div
className={`relative h-full w-full overflow-hidden ${className}`}
style={{ ["--pz-accent" as string]: accent, ...style }}
{...props}
>
<motion.div
ref={viewportRef}
role="region"
aria-roledescription="pan and zoom canvas"
aria-label={label}
aria-describedby={hintId}
tabIndex={0}
className={`absolute inset-0 touch-none select-none overflow-hidden outline-hidden [--pz-dot:rgb(24_24_27/0.16)] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--pz-accent)] dark:[--pz-dot:rgb(255_255_255/0.13)] ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
style={
showGrid
? { backgroundImage: "radial-gradient(circle, var(--pz-dot) 1px, transparent 1.3px)", backgroundSize, backgroundPosition }
: undefined
}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onKeyDown={handleKeyDown}
>
<motion.div ref={contentRef} className="absolute left-0 top-0 origin-top-left" style={{ x, y, scale: zoom }}>
{children}
</motion.div>
</motion.div>
<span id={hintId} className="sr-only">
Drag to pan, scroll or pinch to zoom. When focused: arrow keys pan, plus and minus zoom, 0 resets the view, F fits the content.
</span>
<span aria-live="polite" className="sr-only">
{announcement}
</span>
{showControls ? (
<div
role="toolbar"
aria-label="Zoom"
className="absolute bottom-3 right-3 flex items-center gap-0.5 rounded-lg border border-zinc-900/10 bg-white/90 p-1 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.3)] backdrop-blur-sm dark:border-white/10 dark:bg-[#1a1d21]/90"
>
<button type="button" ref={zoomOutRef} aria-label="Zoom out" className={button} onClick={actions.zoomOut}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">
<path d="M3.5 8h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
aria-label="Zoom to 100%"
title="Zoom to 100%"
className={`${button} w-14 font-mono text-[11px] tabular-nums tracking-tight`}
onClick={() => actions.zoomTo(1)}
>
<span ref={readoutRef}>100%</span>
</button>
<button type="button" ref={zoomInRef} aria-label="Zoom in" className={button} onClick={actions.zoomIn}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">
<path d="M3.5 8h9M8 3.5v9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-zinc-900/10 dark:bg-white/10" />
<button type="button" aria-label="Fit to content" title="Fit to content" className={button} onClick={actions.fitToContent}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">
<path d="M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button type="button" aria-label="Reset view" title="Reset view" className={button} onClick={actions.reset}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">
<path d="M3 8a5 5 0 1 0 1.6-3.7M3 2.5v2.8h2.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
) : null}
</div>
);
});