Skip to main content
Version: 4.3

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 }. setZoom takes a number or (prev) => next and is clamped to the bounds; zoomToFit scales 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.