Skip to main content
Version: 4.3

Next Steps

This is the editor we have assembled: an element palette, a workflow on a scrollable canvas, a Navigator minimap, and a live Properties panel:

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 } = useGraph();

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

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


However, this quickstart covers only a subset of JointJS. Use the links below to go deeper by topic.

  • Elements: element records, typed data, and SVG vs HTML rendering in depth.
  • Links: markers, routing, labels, and custom link rendering.
  • Ports: connection points and link validation.
  • Theming: the --jj-* CSS variables, dark mode, and design tokens for @joint/react and @joint/react-plus.

Manage graph stateโ€‹

  • Accessing State: read the graph with useCells / useCell, selectors, and derived values.
  • Updating State: add, change, and remove cells with useGraph.
  • Controlled Mode: drive the graph from your own state with cells + onCellsChange.

JointJS+ featuresโ€‹

The pieces from the quickstart, each in depth: The Diagram Component, Element Palette (Stencil), Zoom & Scroll (PaperScroller), Navigator (Minimap), Selection, Undo & Redo, Copy & Paste, and more across the Features section.

Go lower-levelโ€‹

  • Building Blocks: jsx() to markup, element selectors, measuring elements, and SVGText.
  • Escape Hatches: drop to the raw JointJS API when you need something the React layer does not wrap.

Common patternsโ€‹

Short, verified snippets to drop into the app you just built.

Validate before connecting: reject links the user shouldn't make (here, only into in ports):

<Paper validateConnection={({ target }) => target.port === 'in'} />

Double-click to edit: open an inline editor on double-click:

const [editingId, setEditingId] = useState(null);

<Paper onElementPointerDblClick={({ id }) => setEditingId(id)} renderElement={/* show an <input> when editingId === useCellId() */} />

Search / filter: hide elements that don't match, using cellVisibility:

const matches = useCells(
(cells) => new Set(cells.filter((c) => c.type === 'element' && c.data.label.includes(query)).map((c) => c.id))
);

<Paper cellVisibility={({ model }) => model.isLink() || matches.has(model.id)} />

Undo / redo without Plus: keep a history stack in React (controlled mode):

const [cells, setCells] = useState(initialCells);
const past = useRef([]);

const undo = () => {
const prev = past.current.pop();
if (prev) setCells(prev);
};

<GraphProvider cells={cells} onCellsChange={(next) => { past.current.push(cells); setCells(next); }}>โ€ฆ</GraphProvider>

Quick referenceโ€‹

A compact recap of the free @joint/react API you met in this guide.

Hooks

HookWhat it returns
useCells(selector?)The whole graph, or a value derived from it. Reactive.
useCell(selector?)The current element (inside renderElement). useCell(id, selector) reads a specific one.
useCellId()The current element's id.
useGraph(){ graph, setCell, setCellData, removeCell, removeCells, resetCells, updateCells }.
useOnPaperEvents(handlers)Subscribe to raw JointJS paper events.

<GraphProvider> props

PropUse
initialCellsUncontrolled: seed the graph once.
cells + onCellsChangeControlled: React owns the cells.
onIncrementalCellsChangeGranular added / changed / removed notifications.

<Paper> props

PropUse
renderElement / renderLinkRender an element / link from its data.
style / classNameSize the canvas with CSS (no width/height props).
linkRoutinglinkRoutingStraight / Orthogonal / Smooth.
interactivefalse, or { elementMove: false, โ€ฆ }.
gridSize, drawGrid, backgroundCanvas appearance.
on* (e.g. onElementPointerClick)Pointer / mouse / context-menu events.
validateConnection, cellVisibilityAllow/deny links; show/hide cells.

Cell shapes

// element
{ id, type: 'element', position: { x, y }, size?: { width, height }, data, portMap? }
// link
{ id, type: 'link', source: { id, port? }, target: { id, port? }, style?, labelMap? }

Link style fields: color, width, targetMarker, sourceMarker, dasharray, linecap, linejoin.

Resourcesโ€‹

Happy diagramming.

<Diagram> wires JointJS+'s standard pointer and keyboard interactions for you, on by default. You don't switch them on one by one; each comes online once its backing feature is present:

InteractionActive once the diagram hasKeyboard
Blank-drag / wheel pan ยท pinch zooma <PaperScroller>โ€”
Click & rubber-band selection ยท deletea <Selection>Delete, Esc, Ctrl/Cmd+A
Undo / redothe history propCtrl/Cmd+Z, Shift+Ctrl/Cmd+Z
Copy / cut / pastethe clipboard prop (with a <Selection>)Ctrl/Cmd+C / X / V

So a bare <Diagram> around a plain <Paper> adds nothing on its own. As you mount <PaperScroller> / <Selection> or set history / clipboard, their interactions appear with no wiring.

Need to turn some off? The interactions prop takes false (disable the whole layer) or an object of switches: paperScroller, selection, clipboard, commandManager, keyboard (the master switch for all shortcuts), plus selectionRegion and wheelAction:

<Diagram interactions={false}>โ€ฆ</Diagram>                       // all off, wire your own
<Diagram interactions={{ paperScroller: false }}>โ€ฆ</Diagram> // keep everything except pan/zoom
<Diagram interactions={{ selectionRegion: false }}>โ€ฆ</Diagram> // click + Cmd+A stay; Shift+drag region off
<Diagram interactions={{ wheelAction: 'zoom' }}>โ€ฆ</Diagram> // wheel zooms instead of pans