Quickstart
Welcome to @joint/react, the React-first way to build diagrams, flowcharts, and node editors. You describe the diagram as data and render each element with your own React components; JointJS handles the canvas, dragging, links that follow their endpoints, geometry, and high-performance rendering.
It is built on JointJS, a mature diagramming engine. You do not need to know JointJS to start here. This guide teaches everything from scratch.
Every element above is a React component. Drag one, and the links follow. That is the whole idea: your data and your components describe the diagram, JointJS makes it interactive.
Reach for @joint/react when your app owns a graph of connected things and you want to render the nodes as your own React components: flowcharts, workflow and pipeline builders, node editors, ER and network diagrams, org charts, decision trees. It may be more than you need for a freeform drawing/whiteboard surface or a plain data chart (bar/line/pie); those have dedicated tools.
By the end of this guide you will have grown that into a complete workflow editor with a palette, scrollable canvas, 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> ); }
That editor is built with @joint/react plus a few JointJS+ (@joint/react-plus) components, and you reach it one small step at a time.
What this guide covers​
This guide works for both JointJS and JointJS+. (The Introduction explains the difference between the editions.)
- The first six chapters use only
@joint/react, so they work on either edition. - From chapter 7 on, each chapter adds one JointJS+ (
@joint/react-plus) piece of the editor: a scrollable, zoomable canvas, selection, then a palette, a property editor, an overlay, and a minimap. The final chapter assembles them into the editor above.
You build the diagram with the core, and JointJS+ builds the editor around it.
The mental model​
Four concepts carry you through everything in this guide. Learn these and the rest is detail.
| Concept | What it is |
|---|---|
GraphProvider | Holds the diagram data (the graph). Everything lives inside it, like a React context for your diagram. |
Paper | The canvas that draws the graph and handles interaction (dragging, clicking, connecting). |
| Cells | The data. A cell is either an element (a box) or a link (a connection between two elements). |
renderElement | The bridge: a function that turns an element's data into your React UI. |
Put together, the smallest possible app reads top-to-bottom like this:
import { GraphProvider, Paper, HTMLHost } from '@joint/react';
import '@joint/react/styles.css';
const initialCells = [
{ id: 'a', type: 'element', position: { x: 40, y: 40 }, data: { label: 'Hello' } },
];
export default function App() {
return (
<GraphProvider initialCells={initialCells}>
<Paper
renderElement={({ label }) => <HTMLHost className="node">{label}</HTMLHost>}
style={{ height: 400 }}
/>
</GraphProvider>
);
}
GraphProvideris fedinitialCells: your starting data.Paperrenders the canvas and is told how to draw an element viarenderElement. It is sized with plain CSS (here an inlineheight).HTMLHostlets an element be plain HTML and auto-sizes it to your content. JointJS draws in SVG, so without it you'd hand-wrap your markup in an SVG<foreignObject>and size it yourself (more in Custom Elements).
Those three pieces are everything you need to put a real diagram on screen.
When using JointJS+ React, <Diagram> is a drop-in upgrade of <GraphProvider> that also owns the diagram-level models the Plus features read. Enable each with a prop:
<Diagram initialCells={initialCells} history clipboard>
…
</Diagram>
Every element on the canvas is a React component you write: a plain function that returns JSX. Nothing to register, nothing to extend. The graph stores your data; your components decide how each element looks.
What you will learn​
A short, focused path. Each chapter builds on the last and ends with a runnable example.
- Install
@joint/react: add the package, import the styles, and size the canvas. - Build your first diagram: render elements from plain data.
- Add and update elements: read the graph and change it while the app runs.
- Create custom elements: rich, data-driven nodes with ports, styled your way.
- Connect elements with links: arrowheads, labels, and routing.
- Make the canvas interactive: dragging, pointer and hover events wired into React state.
- Zoom and pan the canvas: an infinite, scrollable, zoomable viewport with JointJS+.
- Select elements: click-select, multi-select, and a selection box with the built-in JointJS+ selection.
- Add an element palette: drag from a palette onto the canvas.
- Add a property editor: edit the selected node from a live inspector.
- Add an element overlay: React UI anchored to an element, such as a badge, toolbar, or popover.
- Add a minimap: a bird's-eye view that tracks the canvas.
Every chapter ends with a runnable example. Chapters 1–6 build the diagram with @joint/react and show each step in two tabs, a JointJS tab and a JointJS+ tab, so you can see exactly what upgrading adds. Chapters 7–12 then build the editor shell with JointJS+ one piece at a time.