Skip to main content
Version: 4.3

Add a minimap

<Navigator> drops a bird's-eye mini-map next to a scrollable paper. It shows the whole diagram at a glance plus a viewport rectangle that tracks the main canvas. Drag the rectangle to pan, or click anywhere in the overview to jump there:

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
  Diagram,
  ElementOverlay,
  PaperScroller,
  Navigator,
  Selection,
  Stencil,
  useStencil,
  usePaperScroller,
  useSelection,
  usePaperScrollerViewport,
  HTMLHost,
  Paper,
  linkRoutingOrthogonal,
  useGraph,
  useCell,
  useCells,
  selectElementData,
  type CellId,
  type CellRecord,
  type ElementPort,
  type ElementRecord,
} from '@joint/react-plus';
import '@joint/react-plus/styles.css';
import { X } from 'lucide-react';
import { Button } from './components/ui/button';
import { Slider } from './components/ui/slider';
import { ToggleGroup, ToggleGroupItem } from './components/ui/toggle-group';
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 NODE_TYPES: NodeData[] = [
  { label: 'Trigger', description: 'Listens for events', icon: '⚡', color: '#f59e0b' },
  { label: 'Action', description: 'Performs a task', icon: '▶', color: '#22c55e' },
  { label: 'Condition', description: 'Branches the flow', icon: '◆', color: '#a855f7' },
  { label: 'AI Agent', description: 'Runs an AI model', icon: '🤖', color: '#3b82f6' },
];

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>
  );
}

// ── Stencil palette ───────────────────────────────────────────────────────────

function PaletteItem({ nodeData }: Readonly<{ nodeData: NodeData }>) {
  const { startCellDrag } = useStencil();
  const cell: CellRecord<NodeData> = useMemo(
    () => ({ type: 'element', size: NODE_SIZE, portMap: PORTS, data: nodeData }),
    [nodeData]
  );
  return (
    <div className="palette__item" onPointerDown={(event) => startCellDrag(cell, event)}>
      <span style={{ color: nodeData.color }}>{nodeData.icon}</span>
      <span>{nodeData.label}</span>
    </div>
  );
}

function Palette() {
  return (
    <div className="palette">
      <div className="pane-title" style={{ padding: 0 }}>
        Components
      </div>
      {NODE_TYPES.map((node) => (
        <PaletteItem key={node.label} nodeData={node} />
      ))}
    </div>
  );
}

// ── Properties panel ──────────────────────────────────────────────────────────

function NodeEditor({ cellId }: Readonly<{ cellId: CellId }>) {
  const data = useCell(cellId, selectElementData<NodeData>);
  const { setCell, isElement } = useGraph<ElementRecord<NodeData>>();

  const update = useCallback(
    (field: 'label' | 'description') => (event: React.ChangeEvent<HTMLInputElement>) => {
      setCell(cellId, (cell) =>
        isElement(cell) ? { ...cell, data: { ...cell.data, [field]: event.target.value } } : cell
      );
    },
    [cellId, setCell, isElement]
  );

  return (
    <div className="editor">
      <div className="editor__head">
        <span style={{ color: data.color }}>{data.icon}</span>
        <span>{data.label}</span>
      </div>
      <label className="field">
        <span className="field__label">Label</span>
        <input className="field__input" value={data.label} onChange={update('label')} />
      </label>
      <label className="field">
        <span className="field__label">Description</span>
        <input className="field__input" value={data.description} onChange={update('description')} />
      </label>
    </div>
  );
}

function Properties() {
  const { collection } = useSelection();
  const selectedIds = useCells(collection, (cells) => (cells ?? []).map((c) => String(c.id)));

  if (selectedIds.length === 0) {
    return <div className="hint">Click a node to edit it. Shift+drag on blank for multi-select.</div>;
  }

  const elementIds = selectedIds.filter((id) => collection?.get(id)?.isElement());
  if (elementIds.length === 0) {
    return <div className="hint">{selectedIds.length} link(s) selected</div>;
  }

  return (
    <>
      {elementIds.map((id) => (
        <NodeEditor key={id} cellId={id} />
      ))}
    </>
  );
}

// ── Node-actions overlay ──────────────────────────────────────────────────────

function NodeActions() {
  const { removeCell, setCell, isElement } = useGraph<ElementRecord<NodeData>>();
  const { collection } = useSelection();
  const [selected] = useCells(collection, (cells: readonly CellRecord[]) => cells.filter(isElement));

  if (!selected) return null;

  const { id, data } = selected;
  const currentType = NODE_TYPES.find((type) => type.icon === data.icon);

  return (
    <ElementOverlay cell={id} position="top" origin="bottom" dy={-10}>
      <div className="node-overlay">
        <ToggleGroup
          variant="outline"
          size="sm"
          value={currentType ? [currentType.label] : []}
          onValueChange={(value: string[]) => {
            const type = NODE_TYPES.find((candidate) => candidate.label === value[0]);
            if (!type) return;
            setCell(id, (cell) =>
              isElement(cell)
                ? { ...cell, data: { ...cell.data, icon: type.icon, color: type.color, description: type.description } }
                : cell
            );
          }}
        >
          {NODE_TYPES.map((type) => (
            <ToggleGroupItem key={type.label} value={type.label} aria-label={type.label} title={type.label}>
              <span style={{ color: type.color }}>{type.icon}</span>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
        <Button variant="destructive" size="icon-sm" onClick={() => removeCell(id)} aria-label="Remove node">
          <X />
        </Button>
      </div>
    </ElementOverlay>
  );
}

// ── 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
        style={{ height: '100%' }}
        cursor="grab"
        scrollWhileDragging
        minZoom={MIN_ZOOM}
        maxZoom={MAX_ZOOM}
      >
        <Paper gridSize={10} renderElement={RenderElement} linkRouting={ORTHOGONAL_LINKS}>
          <Selection wrapper={{ handles: [] }} />
          <NodeActions />
        </Paper>
      </PaperScroller>
    </div>
  );
}

// ── App ───────────────────────────────────────────────────────────────────────

export default function App() {
  const [cells, setCells] = useState(INITIAL_CELLS);
  return (
    <Diagram cells={cells} onCellsChange={setCells} history clipboard>
      <div className="app">
        <div className="pane-left">
          <Stencil renderElement={RenderElement}>
            <Palette />
          </Stencil>
          <div className="nav-wrap">
            <div className="pane-title">Minimap</div>
            <Navigator className="nav" zoom={false} />
          </div>
        </div>
        <Canvas />
        <div className="pane-right">
          <div className="pane-title">Properties</div>
          <Properties />
        </div>
      </div>
    </Diagram>
  );
}


This is the full editor from the start of this guide: palette, canvas, properties, and now the Navigator docked under the palette in the left sidebar. Pan the main canvas and watch the mini-map's viewport rectangle track along:

<aside>
<Stencil renderElement={RenderElement}>
<Palette />
</Stencil>
<hr />
<Navigator className="nav" zoom={false} />
</aside>

<Navigator> reads the scroll position and zoom bounds straight from the surrounding <PaperScroller>, so it renders happily outside that subtree. It doesn't need to be nested inside it, or even sit next to the canvas. That means it can live anywhere in your layout: floating over a corner of the canvas, or, as here, stacked below an unrelated panel and separated with a plain <hr>. Give it a width/height through CSS (.nav above) and it fills that box; width: 100% makes it span the full sidebar. Reach for it once the diagram outgrows the visible area and people need to keep their bearings.

Navigator uses default styles for its elements and links defined in the stylesheet. Pass elementStyle / linkStyle to restyle either layer from each cell's own data:

<Navigator
elementStyle={({ record }) => ({ fill: record.data.color })}
linkStyle={({ record }) => ({ stroke: record.data.color, strokeWidth: 4 })}
/>

Set zoom to let people resize the viewport rectangle from its corners (bounded by the scroller's own minZoom / maxZoom), or showLinks={false} to drop the link layer when it gets too busy at mini-map scale.

You can find more information in Minimap learn section.

That's the whole picture

You now have every piece the editor at the start of this guide is built from: a palette, a scrollable and zoomable canvas, a live property editor, and a minimap.