Skip to main content
Version: 4.3

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.

ConceptWhat it is
GraphProviderHolds the diagram data (the graph). Everything lives inside it, like a React context for your diagram.
PaperThe canvas that draws the graph and handles interaction (dragging, clicking, connecting).
CellsThe data. A cell is either an element (a box) or a link (a connection between two elements).
renderElementThe 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>
);
}
  • GraphProvider is fed initialCells: your starting data.
  • Paper renders the canvas and is told how to draw an element via renderElement. It is sized with plain CSS (here an inline height).
  • HTMLHost lets 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.

JointJS+

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>
The key idea

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.

  1. Install @joint/react: add the package, import the styles, and size the canvas.
  2. Build your first diagram: render elements from plain data.
  3. Add and update elements: read the graph and change it while the app runs.
  4. Create custom elements: rich, data-driven nodes with ports, styled your way.
  5. Connect elements with links: arrowheads, labels, and routing.
  6. Make the canvas interactive: dragging, pointer and hover events wired into React state.
  7. Zoom and pan the canvas: an infinite, scrollable, zoomable viewport with JointJS+.
  8. Select elements: click-select, multi-select, and a selection box with the built-in JointJS+ selection.
  9. Add an element palette: drag from a palette onto the canvas.
  10. Add a property editor: edit the selected node from a live inspector.
  11. Add an element overlay: React UI anchored to an element, such as a badge, toolbar, or popover.
  12. 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.