Add an element overlay
<ElementOverlay> renders React content on top of <Paper>, anchored to a specific element and kept in sync with its position, size, and the paper's pan/zoom. Reach for it for UI that belongs to a single node without living in its data: a delete button, a status badge, a contextual toolbar.
import { useCallback, useEffect, useMemo } from 'react'; import { Diagram, ElementOverlay, Paper, PaperScroller, Selection, Stencil, useStencil, usePaperScroller, usePaperScrollerViewport, useSelection, useGraph, useCell, useCells, selectElementData, HTMLHost, linkRoutingOrthogonal, 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, isElement } = useGraph<ElementRecord<NodeData>>(); const update = useCallback( (field: 'label' | 'description') => (event: React.ChangeEvent<HTMLInputElement>) => { setCell(cellId, (cell) => isElement(cell) ? { ...cell, data: { ...cell.data, [field]: event.target.value } } : cell ); }, [cellId, setCell, isElement] ); 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.</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() { return ( <Diagram initialCells={INITIAL_CELLS}> <div className="app"> <div className="pane-left"> <Stencil renderElement={RenderElement}> <Palette /> </Stencil> </div> <Canvas /> <div className="pane-right"> <div className="pane-title">Properties</div> <Properties /> </div> </div> </Diagram> ); }
Click a node to select it, then use the toolbar that floats above it: a ToggleGroup switches the node's type, and a Button removes it. Both are shadcn components, and both live in a single <ElementOverlay> mounted as a sibling of <Selection> inside <Paper>, reading the same selection collection as the property editor:
function NodeActions() {
const { removeCell, setCell, isElement } = useGraph<ElementRecord<NodeData>>();
const { collection } = useSelection();
const [selected] = useCells(collection, (cells) => 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
value={currentType ? [currentType.label] : []}
onValueChange={(value) => {
const type = NODE_TYPES.find((candidate) => candidate.label === value[0]);
if (!type) return;
setCell(id, (cell) => ({
...cell,
data: { ...cell.data, icon: type.icon, color: type.color, description: type.description },
}));
}}
>
{NODE_TYPES.map((type) => (
<ToggleGroupItem key={type.label} value={type.label} aria-label={type.label}>
{type.icon}
</ToggleGroupItem>
))}
</ToggleGroup>
<Button variant="destructive" size="icon-sm" onClick={() => removeCell(id)} aria-label="Remove node">
<X />
</Button>
</div>
</ElementOverlay>
);
}
Note, that we are using useSelection to read the current selection, and useCells to filter it down to just the selected element. The overlay is only rendered when an element is selected, and it is anchored to that element's position on the canvas.
cell={id}targets a specific element.positionis the point on the element's bounding box that the overlay anchors to;originis the point on the overlay itself that lands there. Here the overlay's bottom edge sits on the element's top edge, so the toolbar floats just above the node.dy={-10}nudges the whole overlay 10 further screen pixels up.dx/dyare in screen pixels, so the gap stays constant at any zoom level.- Because the overlay reads the same
useSelection()collection as the properties panel, they always agree on which node is active, filtered here withisElement, since<ElementOverlay>anchors to an element, not a link. - The two controls live inside one overlay, laid out with a plain flex row (
.node-overlay). No second<ElementOverlay>, nodx/dybookkeeping to keep them apart. Reach for a second<ElementOverlay>only when pieces genuinely need independent anchors (say, one badge pinned to a corner and another centered).
<ElementOverlay> must be mounted inside <Paper>, either as a direct child, as here, or inline inside renderElement (drop the cell prop) when every element of a type should carry the same overlay.
<Paper renderElement={RenderElement}>
<Selection />
<NodeActions />
</Paper>
You can find more information in Overlay learn section.