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