Add an element palette
<Stencil> is the palette you drag new elements from. It renders no UI of its own; you build the tiles with React, and useStencil() gives each one a startCellDrag(cell, event) that drops a fresh cell onto the linked <Paper>:
import { useEffect, useMemo } from 'react'; import { Diagram, Paper, PaperScroller, Selection, Stencil, useStencil, useIsCellSelected, usePaperScroller, usePaperScrollerViewport, HTMLHost, linkRoutingOrthogonal, type CellRecord, type ElementPort, } from '@joint/react-plus'; import '@joint/react-plus/styles.css'; import { Slider } from './components/ui/slider'; 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>) { const isSelected = useIsCellSelected(); return ( <HTMLHost useModelGeometry className={isSelected ? 'node node--selected' : '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> ); } // ── 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: [] }} /> </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> </Diagram> ); }
Drag a component from the left onto the canvas to add it to the workflow. The palette sits next to the same scrollable canvas from the previous chapter:
<Stencil renderElement={RenderElement}>
<Palette />
</Stencil>
Here is what the palette component looks like:
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>
);
}
As you can see this is just a React component that renders a list of tiles. Each tile is a PaletteItem that calls startCellDrag on its pointerdown:
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>
);
}
The cell passed to startCellDrag is an ordinary CellRecord with no id or position (both are assigned when it lands), so one piece of data describes both the palette tile and the element it becomes.
<Stencil> and the drag preview reuses the paper's own renderer, so it looks exactly like the element it will become. You can pass renderElement to give the preview its own look, or a dropCell option to land a different cell than the one you drag (a compact thumbnail that drops a full card, say).
More in Element Palette learn section, including reactive drag previews with useCellDrag(), validating or rejecting drops, and the full drag-lifecycle events.