Zoom and Pan
So far the canvas has been exactly as big as its container. JointJS+'s <PaperScroller> wraps a <Paper> in a scrollable, zoomable viewport. It owns the canvas viewport: pan and zoom gestures, the canvas mode, and programmatic control.
import { useEffect } from 'react'; import { Diagram, Paper, PaperScroller, usePaperScroller, usePaperScrollerViewport, HTMLHost, linkRoutingOrthogonal, type CellRecord, type ElementPort, } from '@joint/react-plus'; import { Slider } from './components/ui/slider'; import '@joint/react-plus/styles.css'; import './example.css'; // ── Types & config ──────────────────────────────────────────────────────────── interface NodeData { readonly label: string; readonly description: string; readonly icon: string; readonly color: string; } const ORTHOGONAL_LINKS = linkRoutingOrthogonal(); const NODE_SIZE = { width: 160, height: 52 }; const MIN_ZOOM = 0.2; const MAX_ZOOM = 3; const PORTS: Record<string, ElementPort> = { in: { cx: 0, cy: '50%', width: 10, height: 10, passive: true }, out: { cx: '100%', cy: '50%', width: 10, height: 10 }, }; const INITIAL_CELLS: readonly CellRecord[] = [ { id: 'n1', type: 'element', position: { x: 60, y: 40 }, size: NODE_SIZE, portMap: PORTS, data: { label: 'New Ticket', description: 'Listens for events', icon: '⚡', color: '#f59e0b' }, }, { id: 'n2', type: 'element', position: { x: 60, y: 160 }, size: NODE_SIZE, portMap: PORTS, data: { label: 'Classify', description: 'Analyze ticket type', icon: '🤖', color: '#3b82f6' }, }, { id: 'n3', type: 'element', position: { x: 300, y: 100 }, size: NODE_SIZE, portMap: PORTS, data: { label: 'Is Urgent?', description: 'Check priority', icon: '◆', color: '#a855f7' }, }, { id: 'n4', type: 'element', position: { x: 300, y: 240 }, size: NODE_SIZE, portMap: PORTS, data: { label: 'Send Reply', description: 'Auto-respond', icon: '▶', color: '#22c55e' }, }, { id: 'n1→n3', type: 'link', source: { id: 'n1', port: 'out' }, target: { id: 'n3', port: 'in' }, style: { color: '#94a3b8' } }, { id: 'n2→n3', type: 'link', source: { id: 'n2', port: 'out' }, target: { id: 'n3', port: 'in' }, style: { color: '#94a3b8' } }, { id: 'n3→n4', type: 'link', source: { id: 'n3', port: 'out' }, target: { id: 'n4', port: 'in' }, style: { color: '#94a3b8' } }, ]; // ── Element renderer ────────────────────────────────────────────────────────── function RenderElement({ label, description, icon, color }: Readonly<NodeData>) { return ( <HTMLHost useModelGeometry className="node"> <span className="node__icon" style={{ color }}> {icon} </span> <div className="node__body"> <div className="node__label">{label}</div> <div className="node__desc">{description}</div> </div> </HTMLHost> ); } // ── Canvas ──────────────────────────────────────────────────────────────────── function ZoomControl() { const { setZoom } = usePaperScroller(); const { zoom } = usePaperScrollerViewport(); return ( <div className="zoom-control"> <Slider className="zoom-control__slider" min={MIN_ZOOM} max={MAX_ZOOM} step={0.01} value={zoom} onValueChange={(value) => setZoom(value as number)} /> <span className="zoom-control__value">{Math.round(zoom * 100)}%</span> </div> ); } function Canvas() { const { paperScroller } = usePaperScroller(); useEffect(() => { if (paperScroller) paperScroller.centerContent({ useModelGeometry: true }); }, [paperScroller]); return ( <div className="pane-center"> <ZoomControl /> <PaperScroller mode="infinite" style={{ height: '100%' }} minZoom={MIN_ZOOM} maxZoom={MAX_ZOOM} > <Paper gridSize={10} renderElement={RenderElement} linkRouting={ORTHOGONAL_LINKS} /> </PaperScroller> </div> ); } // ── App ─────────────────────────────────────────────────────────────────────── export default function App() { return ( <Diagram initialCells={INITIAL_CELLS} interactions={{ wheelAction: 'pan' }}> <div className="app"> <Canvas /> </div> </Diagram> ); }
Drag the blank canvas to pan, scroll or pinch to zoom, or drag the slider in the corner; all three stay in sync. <PaperScroller> sizes itself to its container, so give it a height:
<PaperScroller mode="infinite" style={{ height: 600 }}>
<Paper gridSize={10} renderElement={RenderElement} />
</PaperScroller>
<PaperScroller> has two layout modes: mode="infinite" (the default) is a borderless canvas that grows in every direction, and mode="sheets" lays the content out on fixed pages. See Zoom & Scroll for the full mode and viewport options.
Blank-drag pan, wheel, and pinch zoom are wired for you the moment a <PaperScroller> sits inside <Diagram>. By default the wheel pans; if you'd rather it zoom, set that on the <Diagram> interactions. It's a preference for how your app should feel:
<Diagram interactions={{ wheelAction: 'zoom' }}>
Whichever you pick, Ctrl/⌘ + wheel and trackpad pinch also zoom.
Accessing values and moving the viewport
<PaperScroller> exposes a React context with the current viewport values and methods to move it around. Use usePaperScroller() and usePaperScrollerViewport() to read and move the viewport from anywhere inside <Diagram>:
function ZoomControl() {
const { setZoom } = usePaperScroller();
const zoom = usePaperScrollerViewport((v) => v.zoom); // re-renders only on zoom change
return (
<Slider
min={0.4}
max={2}
step={0.01}
value={zoom}
onValueChange={(value) => setZoom(value as number)}
/>
);
}
usePaperScroller()→{ paperScroller, setZoom, zoomToFit, startPaperPan }.setZoomtakes a number or(prev) => nextand is clamped to the bounds;zoomToFitscales so all content fits.usePaperScrollerViewport()→{ zoom, canZoomIn, canZoomOut, visibleArea }, reactive. Pass a selector to subscribe to one value. Before the scroller mounts you get a safe default (zoom: 1), so no null-checks.
More in Zoom & Scroll.
Next, let people select elements on that canvas.