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.
Customize elements, links & portsโ
- 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/reactand@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, andSVGText. - 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
| Hook | What 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
| Prop | Use |
|---|---|
initialCells | Uncontrolled: seed the graph once. |
cells + onCellsChange | Controlled: React owns the cells. |
onIncrementalCellsChange | Granular added / changed / removed notifications. |
<Paper> props
| Prop | Use |
|---|---|
renderElement / renderLink | Render an element / link from its data. |
style / className | Size the canvas with CSS (no width/height props). |
linkRouting | linkRoutingStraight / Orthogonal / Smooth. |
interactive | false, or { elementMove: false, โฆ }. |
gridSize, drawGrid, background | Canvas appearance. |
on* (e.g. onElementPointerClick) | Pointer / mouse / context-menu events. |
validateConnection, cellVisibility | Allow/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โ
- API reference: react.jointjs.com/api
- Live examples (Storybook): react.jointjs.com/learn
- Source & issues: github.com/clientIO/joint
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:
| Interaction | Active once the diagram has | Keyboard |
|---|---|---|
| Blank-drag / wheel pan ยท pinch zoom | a <PaperScroller> | โ |
| Click & rubber-band selection ยท delete | a <Selection> | Delete, Esc, Ctrl/Cmd+A |
| Undo / redo | the history prop | Ctrl/Cmd+Z, Shift+Ctrl/Cmd+Z |
| Copy / cut / paste | the 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