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.