@pyreon/flow provides reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom without D3, a built-in seven-mode layout engine (code-split, zero dependencies), and per-node O(1) reactivity. Built from the ground up for signal-based frameworks — each node mounts exactly once across the lifetime of the graph, and a drag, selection click, or updateNode patches that node in place instead of remounting it.
Installation
npm install @pyreon/flow @pyreon/runtime-dombun add @pyreon/flow @pyreon/runtime-dompnpm add @pyreon/flow @pyreon/runtime-domyarn add @pyreon/flow @pyreon/runtime-domQuick Start
import { createFlow, Flow, Background, MiniMap, Controls } from '@pyreon/flow'
const flow = createFlow({
fitView: true,
nodes: [
{ id: '1', type: 'input', position: { x: 0, y: 0 }, data: { label: 'Start' } },
{ id: '2', position: { x: 200, y: 100 }, data: { label: 'Process' } },
{ id: '3', type: 'output', position: { x: 400, y: 0 }, data: { label: 'End' } },
],
edges: [
{ source: '1', target: '2' },
{ source: '2', target: '3' },
],
})
function WorkflowBuilder() {
return (
<Flow instance={flow}>
<Background />
<MiniMap />
<Controls />
</Flow>
)
}No callbacks, no applyNodeChanges. The flow instance manages everything.
Playground
The kitchen-sink demo — every visible feature in one graph. Click an auto-layout button (layered →, layered ↓, tree, force) and the nodes animate to the engine-computed positions. The edges show the full arrow vocabulary: filled ArrowClosed, open Arrow chevrons, per-edge colours, and a both-ends marker. Drag any node, click to select, drag from a node edge to connect.
Creating a Flow
createFlow() accepts a config object and returns a reactive FlowInstance:
const flow = createFlow({
nodes: [...],
edges: [...],
snapToGrid: true,
snapGrid: 20,
connectionRules: { input: { outputs: ['process'] } },
nodeExtent: [[0, 0], [1000, 800]],
})Generic over node data
createFlow<TData>(...) is generic over the shape of each node's data payload. Pass your type explicitly and node.data.kind narrows correctly — no [key: string]: unknown index signature required on your data interface:
interface WorkflowData {
kind: 'trigger' | 'filter' | 'transform' | 'notify'
label: string
}
const flow = createFlow<WorkflowData>({
nodes: [
{ id: '1', type: 'custom', position: { x: 0, y: 0 }, data: { kind: 'trigger', label: 'Start' } },
],
edges: [],
})
// node.data.kind narrows to the typed union, not `unknown`
const triggers = flow.findNodes((n) => n.data.kind === 'trigger')When no generic is supplied it defaults to Record<string, unknown>.
Config Options
| Option | Type | Default | Description |
|---|---|---|---|
nodes | FlowNode<TData>[] | [] | Initial nodes |
edges | FlowEdge[] | [] | Initial edges |
defaultEdgeType | EdgeType | 'bezier' | Edge type applied when an edge omits type |
defaultEdgeOptions | DefaultEdgeOptions | — | Defaults merged into every edge that doesn't set the field itself (see Default Edge Options) |
minZoom | number | 0.1 | Minimum zoom level |
maxZoom | number | 4 | Maximum zoom level |
snapToGrid | boolean | false | Snap node positions to a grid |
snapGrid | number | 15 | Grid size in pixels |
snapToObjects | boolean | true | Align-to-other-nodes helper lines on drag (see Object Snapping) |
connectionRules | ConnectionRule | — | Validate connections by source node type (see Connection Rules) |
nodesDraggable | boolean | true | Whether nodes are draggable by default |
nodesConnectable | boolean | true | Whether nodes are connectable by default |
nodesSelectable | boolean | true | Whether nodes are selectable by default |
multiSelect | boolean | true | Allow multi-selection |
nodeExtent | [[minX, minY], [maxX, maxY]] | — | Constrain node positions within bounds |
pannable | boolean | true | Whether panning is enabled |
zoomable | boolean | true | Whether zooming is enabled |
fitView | boolean | false | Fit all nodes in the viewport on initial render |
fitViewPadding | number | 0.1 | Padding ratio used by fitView() |
defaultMarkerEnd | EdgeMarkerSpec | null | { type: ArrowClosed } | Default END arrowhead; null makes edges arrowless |
onlyRenderVisibleElements | boolean | false | Cull off-screen nodes/edges (see Render Virtualization) |
useFlow(config) — Component-Scoped Flows
For flows that live and die with a component, use useFlow instead of createFlow. It wraps the instance with an onUnmount(() => flow.dispose()) so you don't write the disposal boilerplate yourself. It has the identical signature to createFlow (including the <TData> generic).
import { useFlow, Flow, Background } from '@pyreon/flow'
const MyDiagram = () => {
const flow = useFlow<WorkflowData>({
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: { kind: 'trigger', label: 'Start' } }],
edges: [],
})
return (
<Flow instance={flow}>
<Background />
</Flow>
)
}Use createFlow directly only when the flow is owned outside the component tree (app store, singleton, SSR-shared state) — those cases require a manual flow.dispose() at the correct lifecycle point.
Reactive Signals
All state is exposed as reactive signals. nodes, edges, viewport, and containerSize are writable Signals; the rest are derived Computeds.
flow.nodes() // Signal<FlowNode<TData>[]>
flow.edges() // Signal<FlowEdge[]>
flow.viewport() // Signal<Viewport> — { x, y, zoom }
flow.containerSize() // Signal<{ width, height }> — set by <Flow> via ResizeObserver
flow.zoom() // Computed<number> — just the zoom level
flow.selectedNodes() // Computed<string[]> — selected node ids
flow.selectedEdges() // Computed<string[]> — selected edge ids
flow.nodeMap() // Computed<Map<string, FlowNode>> — O(1) lookup, rebuilt once per nodes() change
flow.edgeMap() // Computed<Map<string, FlowEdge>> — O(1) lookup, rebuilt once per edges() changeCustom Node Renderers
Register custom node components by node.type via <Flow nodeTypes={{ ... }}>. The component receives NodeComponentProps<TData>.
Every prop except id is a reactive accessor (() => T), not a plain value. Read each one inside a reactive scope — a JSX expression thunk, effect(), or computed() — so the node patches in place when the underlying state changes.
import { Flow, Handle, Position, type NodeComponentProps } from '@pyreon/flow'
function CustomNode(props: NodeComponentProps<WorkflowData>) {
return (
<div
class={() => (props.selected() ? 'node selected' : 'node')}
style={() => `cursor: ${props.dragging() ? 'grabbing' : 'grab'}`}
>
<Handle type="target" position={Position.Left} />
{() => props.data().label}
<Handle type="source" position={Position.Right} />
</div>
)
}
<Flow instance={flow} nodeTypes={{ custom: CustomNode }}>
<Background />
</Flow>| Prop | Type | Description |
|---|---|---|
id | string | Stable node identity — never changes; the keyed identity, plain string |
data | () => TData | Accessor for the node's current data (reflects updateNode mutations) |
selected | () => boolean | Accessor — whether the node is selected |
dragging | () => boolean | Accessor — whether the node is being dragged |
Custom edge renderers (registered via edgeTypes={{ ... }}) follow the same contract — sourceX/sourceY/targetX/targetY/selected are all accessors recomputed when the endpoints move.
Node Operations
// Add a node
flow.addNode({ id: '4', position: { x: 300, y: 200 }, data: { label: 'New Node' } })
// Remove a node (also removes connected edges)
flow.removeNode('4')
// Update node properties (shallow-merged onto the node)
flow.updateNode('2', { data: { label: 'Updated' } })
// Update position (respects snapToGrid and nodeExtent clamping)
flow.updateNodePosition('2', { x: 250, y: 150 })
// Get a specific node
const node = flow.getNode('2') // FlowNode<TData> | undefinedBatch node operations
flow.getNodes() // plain (untracked) read
flow.addNodes([n1, n2]) // one batch; duplicate ids are ignored
flow.setNodes((nodes) => nodes.filter((n) => n.id !== 'x')) // edges + selection pruned to survivors
flow.removeNodes(['a', 'b']) // nodes and their edges, one batch
flow.updateNodeData('a', { label: 'A!' }) // merge into data (or a function of the node)hidden and deletable
A node or edge with hidden: true stays in the graph (ids, edges, selection, JSON) but is not rendered — an edge touching a hidden node disappears with it. deletable: false exempts an element from deleteSelected() and the Delete key (it stays selected so the user can see it survived); nodesDeletable: false / edgesDeletable: false set the default. An edge whose endpoint node is deleted goes regardless of its own flag.
Sub-flows (parent / child nodes)
const flow = createFlow({
nodes: [
{ id: 'group', position: { x: 100, y: 200 }, width: 400, height: 300, group: true, data: {} },
// A child's position is RELATIVE to its parent's top-left.
{ id: 'a', parentId: 'group', position: { x: 20, y: 30 }, data: {}, extent: 'parent' },
{ id: 'b', parentId: 'group', position: { x: 200, y: 30 }, data: {}, expandParent: true },
],
})
flow.getAbsolutePosition('a') // { x: 120, y: 230 }React Flow's sub-flow model: a child keeps its relative position, renders at the absolute one, and moves with its parent (one write on the parent — dragging a parent and a selected child together never double-moves the child). Edges, fitView, focusNode, the selection box, viewport culling and the minimap all use absolute positions. Parents render before their children so a child sits on top. extent: 'parent' keeps a dragged child inside its parent's box (an [[minX, minY], [maxX, maxY]] extent clamps it to a box in its own coordinate space); expandParent: true grows the parent's width / height to contain a child dragged past its edge.
Edge Operations
// Add an edge — id auto-generated from source/target if not provided
flow.addEdge({ source: '1', target: '3' })
// Add with type and label
flow.addEdge({ source: '1', target: '3', type: 'smoothstep', label: 'yes' })
// Remove an edge by id (auto-generated ids look like `e-1-3`)
flow.removeEdge('e-1-3')
// Get a specific edge
const edge = flow.getEdge('e-1-3')
// Reconnect an edge to a new source/target (or handles)
flow.reconnectEdge('e-1-3', { target: '5' })Duplicate edges (same generated id) are silently skipped by addEdge.
Edge Types
Four built-in edge path algorithms (set per edge via type, or graph-wide via defaultEdgeType):
| Type | Description |
|---|---|
bezier | Smooth cubic bezier curve (default) |
smoothstep | Right-angle path with rounded corners |
step | Right-angle path with sharp corners |
straight | Direct line between nodes |
EdgeType is 'bezier' | 'smoothstep' | 'straight' | 'step' | (string & {}) — the open string union lets you register custom edge renderers under any name via <Flow edgeTypes={{ ... }}>.
Custom Edge Renderers
Register a renderer under any name and set that name as an edge's type. The renderer receives EdgeComponentProps: the stable edge object (read per-edge config like edge.pathOptions / edge.data from it) plus reactive accessors — endpoint coordinates, the tangent sides (sourcePosition() / targetPosition() — which side the edge departs from / approaches, resolved from handles or the floating-endpoint math), and selected(). Each edge component mounts exactly once; read the accessors inside JSX thunks so the path patches in place during drags.
import { getBezierPath, type EdgeComponentProps } from '@pyreon/flow'
function GlowEdge(props: EdgeComponentProps) {
return (
<path
d={() =>
getBezierPath({
sourceX: props.sourceX(), sourceY: props.sourceY(),
sourcePosition: props.sourcePosition(),
targetX: props.targetX(), targetY: props.targetY(),
targetPosition: props.targetPosition(),
...props.edge.pathOptions,
}).path
}
style={() =>
`fill: none; stroke-width: 3; stroke: ${props.selected() ? 'var(--pyreon-flow-accent, #3b82f6)' : 'var(--pyreon-flow-edge, #999)'};`
}
/>
)
}
<Flow instance={flow} edgeTypes={{ glow: GlowEdge }} />
flow.addEdge({ source: '1', target: '2', type: 'glow' })Feeding sourcePosition() / targetPosition() into the path builders makes a custom edge depart/approach at the same natural angle the built-ins use. Set colors via style (never SVG presentation attributes — var() is invalid there).
Per-Edge Path Options
Each built-in path builder is tunable per edge via pathOptions (only the fields relevant to the edge's type apply):
// A flatter bezier (default curvature is 0.25; 0 = straight line)
flow.addEdge({ source: '1', target: '2', pathOptions: { curvature: 0.1 } })
// A smoothstep with bigger rounded corners and a longer straight run-out
flow.addEdge({
source: '2',
target: '3',
type: 'smoothstep',
pathOptions: { borderRadius: 12, offset: 40 },
})
// A step edge that runs 60px straight out of each endpoint before turning
flow.addEdge({ source: '3', target: '4', type: 'step', pathOptions: { offset: 60 } })| Field | Applies to | Default | Description |
|---|---|---|---|
curvature | bezier | 0.25 | Control-point strength as a fraction of endpoint distance |
borderRadius | smoothstep | 5 | Corner rounding radius in px (step locks this to 0) |
offset | smoothstep, step | 20 | Straight run-out from each endpoint before the first turn, in px |
straight and waypoint-routed edges ignore pathOptions.
Default Edge Options
defaultEdgeOptions merges defaults into every edge that doesn't set the field itself — the initial edges array, every addEdge(), and every connection drawn by the user:
const flow = createFlow({
nodes,
edges,
defaultEdgeOptions: {
type: 'smoothstep',
animated: true,
pathOptions: { borderRadius: 8 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#6366f1' },
},
})A per-edge explicit value always wins — including an explicit markerEnd: null (arrowless opt-out). type resolves as edge.type → defaultEdgeOptions.type → defaultEdgeType → 'bezier'.
Edge Waypoints
Add bend points to edges:
flow.addEdgeWaypoint('e-1-2', { x: 150, y: 50 })
flow.addEdgeWaypoint('e-1-2', { x: 200, y: 75 }, 1) // insert at a specific index
flow.updateEdgeWaypoint('e-1-2', 0, { x: 160, y: 60 })
flow.removeEdgeWaypoint('e-1-2', 0)Edge Markers
See the playground above for every marker shape rendered live.
Configurable arrowheads on either end of an edge. Two shapes — MarkerType.ArrowClosed (filled triangle, the default) and MarkerType.Arrow (open chevron) — with optional color / width / height / strokeWidth. Identical marker configs are deduplicated into a single shared <defs> entry across the whole graph, and the <defs> set rebuilds reactively as edges are added/removed.
import { MarkerType } from '@pyreon/flow'
flow.addEdge({ id: 'e1', source: '1', target: '2' }) // default closed arrow at end
flow.addEdge({ id: 'e2', source: '2', target: '3', markerEnd: MarkerType.Arrow }) // open chevron
flow.addEdge({
id: 'e3',
source: '3',
target: '4',
markerStart: MarkerType.ArrowClosed, // arrow on BOTH ends
markerEnd: { type: MarkerType.Arrow, color: '#ff0000', width: 14 },
})
flow.addEdge({ id: 'e4', source: '4', target: '5', markerEnd: null }) // explicitly arrowlessSet the graph-wide default (or turn arrows off everywhere) in the flow config:
const flow = createFlow({
nodes,
edges,
defaultMarkerEnd: null, // every edge arrowless unless it opts in via markerEnd
})Batch edge operations
flow.getEdges()
flow.addEdges([{ source: 'a', target: 'b' }]) // normalised (id/type/defaults), deduped, emits onConnect + onEdgesChange
flow.setEdges((edges) => edges.filter((e) => !e.animated))
flow.removeEdges(['ab'])
flow.updateEdge('ab', { label: 'renamed' })Hit width and reconnection
Every edge carries an invisible interaction path around its visible line (edgeInteractionWidth, default 20px; per-edge interactionWidth), so a hairline edge is clickable. A selected edge shows two endpoint handles: drag one onto another node's handle to reconnect that end (the other end stays fixed, isValidConnection / connectionRules are consulted, and a drop on nothing leaves the edge as it was). Turn the handles off per edge with reconnectable: false or globally with edgesReconnectable: false.
Connection line
connectionLineType ('bezier' default, 'straight', 'smoothstep', 'step') picks the built-in in-progress line; <Flow connectionLine={MyLine}> replaces it with your own component, mounted once per drag with accessor props (sourceX/Y, targetX/Y, sourcePosition, and the built-in path for the same points) that follow the pointer without re-mounting.
Edge Anchoring & Node Measurement
Where exactly an edge attaches to a node is resolved per endpoint, in priority order:
A measured
<Handle>dot. The renderer measures every node's real DOM box and every<Handle>dot's center (client-only, via a per-nodeResizeObserver).edge.sourceHandle/edge.targetHandleanchor the edge exactly at the named dot — wherever your CSS placed it. An edge with no handle id uses the node's first handle of the right type (source/target).A config handle. A handle declared in
node.sourceHandles/node.targetHandlesthat hasn't been measured yet (first frame, SSR) anchors at its declared side's midpoint.Floating endpoints. A node with no handles connects where the center-to-center line crosses its perimeter — the edge approaches at the natural angle instead of docking at a fixed side midpoint (React Flow's floating-edge model). This is the default for plain nodes.
// Anchors at the 'out-fallback' dot's real rendered position:
flow.addEdge({ source: '1', sourceHandle: 'out-fallback', target: '2' })An edge that names a handle id that exists on neither the measured dots nor the config handles anchors at the node's first handle instead and dev-warns once naming the known ids — a typo never silently produces a dead edge.
Measured node dimensions
Nodes don't need explicit width / height — the renderer measures the real rendered box and every geometry consumer uses the same effective dimensions rule:
explicit
node.width/node.height→ measured DOM size →150×40default
That rule drives edge anchoring, auto-layout (ELK receives real boxes), fitView, drag snap lines, rubber-band selection hit-tests, the minimap, <NodeResizer>'s starting size, and viewport culling. Read a node's effective box imperatively via flow.getNodeDimensions(id), or reactively via the flow.measurements() map (Map<string, NodeMeasurement> — size plus measured handle dots).
Selection
flow.selectNode('1') // select a node (replaces selection)
flow.selectNode('2', true) // additive — add to selection (2nd arg is `additive`)
flow.deselectNode('1') // remove from selection
flow.selectEdge('e-1-2') // select an edge
flow.selectEdge('e-2-3', true) // additive
flow.selectAll() // select all nodes
flow.clearSelection() // deselect everything
flow.deleteSelected() // remove selected nodes and edges
flow.moveSelectedNodes(20, 0) // move every selected node by dx/dyViewport
flow.zoomIn() // zoom in (×1.2, clamped to maxZoom)
flow.zoomOut() // zoom out (÷1.2, clamped to minZoom)
flow.zoomTo(1.5) // set exact zoom (clamped to min/max)
flow.fitView() // fit all nodes in viewport
flow.fitView(['1', '2']) // fit specific nodes
flow.fitView(['1', '2'], 0.2) // with a custom padding ratio
flow.panTo({ x: 100, y: 200 }) // pan so a graph position is at the origin
flow.focusNode('3') // pan to center a node + select it
flow.focusNode('3', 1.5) // ...with a target zoom
flow.animateViewport({ zoom: 2 }) // animate viewport to a partial target (default 300ms)
flow.zoom() // Computed<number> — reactive zoom level
flow.isNodeVisible('1') // boolean — is the node within the current viewportRender Virtualization
For large graphs, set onlyRenderVisibleElements: true so only nodes whose screen rect intersects the viewport (expanded by a margin so they don't pop in at the edge) — and the edges touching at least one visible node — are mounted in the DOM. The rendered set re-filters reactively on pan and zoom.
const flow = createFlow({
nodes, // thousands of nodes
edges,
onlyRenderVisibleElements: true,
})Object Snapping (drag perf)
By default a dragged node snaps to align with other nodes' edges and centers, drawing helper guide lines (snapToObjects: true). That alignment scan runs over every node on every drag frame — on large graphs it's the dominant per-frame cost. Turn it off to skip the scan entirely:
const flow = createFlow({
nodes, // hundreds / thousands of nodes
edges,
snapToObjects: false, // skip the per-frame O(N) align scan — ~3-4× faster drags
})Measured (60-frame drag): N=1000 1.34ms → 0.31ms, N=3000 3.36ms → 0.78ms. Default stays true so existing apps keep helper-line snapping; set false when you don't need it (or use snapToGrid for grid quantization instead, which is a separate, cheap mechanism).
Viewport helpers
flow.getViewport() // { x, y, zoom } — untracked read
flow.setViewport({ zoom: 2 }) // partial write; omitted fields keep their value
flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 300 }) // animated
flow.setCenter(400, 200, { zoom: 1.5, duration: 200 }) // center on a FLOW point
flow.zoomTo(2, { duration: 150 }) // zoomIn / zoomOut / fitView take the same options
flow.screenToFlowPosition({ x: e.clientX, y: e.clientY }) // drop a node where the pointer is
flow.flowToScreenPosition(node.position) // anchor a popover to a nodeA plain write cancels an in-flight animation. The screen conversions use the mounted <Flow> container's rect; with no mounted canvas a screen point is read as canvas-relative.
Pan and zoom options
const flow = createFlow({
panOnDrag: [1, 2], // pan with the middle / right button only (default: true = any button)
selectionOnDrag: true, // a plain left-drag draws a selection box (the "figma" preset)
selectionMode: 'full', // box must CONTAIN a node ('partial', the default, touches)
panOnScroll: true, // the wheel pans; Shift+wheel horizontally; Ctrl/Cmd+wheel zooms
panOnScrollSpeed: 0.5,
zoomOnScroll: true,
zoomOnPinch: true,
zoomOnDoubleClick: true, // double-click the empty canvas: one zoom step around the pointer
preventScrolling: true, // preventDefault() on handled wheel events
})Keys
const flow = createFlow({
deleteKeys: ['Delete', 'Backspace'], // null disables keyboard deletion
multiSelectionKey: 'shift', // modifier that ADDS a click to the selection ('ctrl' | 'meta' | 'alt' | null)
selectionKey: 'shift', // modifier that turns a canvas drag into a selection box
zoomActivationKey: 'ctrl', // zoom modifier under panOnScroll (Cmd also counts by default)
})Auto-Layout
The playground above has live layered, tree, and force buttons — click them to watch the layout animate.
Layout nodes automatically with the built-in layout engine (seven modes — layered, force, stress, tree, radial, box, rectpacking — pure geometry, zero dependencies, code-split so a flow that never calls layout() does not load it). The engine chunk is fetched the first time flow.layout() runs:
// Layered layout (DAG/pipeline)
await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 })
// Tree layout
await flow.layout('tree', { direction: 'DOWN', nodeSpacing: 40 })
// Force-directed
await flow.layout('force', { nodeSpacing: 80 })
// Other algorithms
await flow.layout('stress')
await flow.layout('radial')
await flow.layout('box')
await flow.layout('rectpacking')flow.layout(algorithm?, options?) defaults to 'layered'. It returns a Promise<void> and (unless animate: false) interpolates node positions over animationDuration with an ease-out cubic.
flow.layout() feeds ELK each node's effective dimensions (explicit → measured DOM size → default, see Measured node dimensions) — content-sized custom nodes are laid out at their real rendered size, so spacing is correct instead of assuming 150×40 boxes. The standalone computeLayout(nodes, edges, algorithm?, options?) export has no DOM access and uses explicit-or-default sizes; set width/height on the nodes you pass it for precise headless layout.
Layout Options
| Option | Type | Default | Description |
|---|---|---|---|
direction | 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'DOWN' | Layout direction |
nodeSpacing | number | 50 | Spacing between nodes |
layerSpacing | number | 80 | Spacing between layers |
edgeRouting | 'orthogonal' | 'splines' | 'polyline' | 'orthogonal' | How edges are routed |
animate | boolean | true | Animate the layout transition |
animationDuration | number | 300 | Animation duration in milliseconds |
Algorithm Applicability
Not every option applies to every algorithm — ELK namespaces options under specific pipelines, so passing direction to a force layout has zero effect. The table below is empirically verified (each cell records whether running the algorithm twice with two different values for that option produces a different layout ✅ or an identical one ❌):
| Option | layered | tree | force | stress | radial | box | rectpacking |
|---|---|---|---|---|---|---|---|
direction | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
nodeSpacing | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
layerSpacing | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
edgeRouting | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
The same logic is exposed standalone as computeLayout(nodes, edges, algorithm?, options?), which returns Promise<Array<{ id, position }>> without mutating the flow — useful if you want to compute positions and apply them yourself.
Connection Rules
Define type-safe rules for which node types can connect. The map is keyed by source node type; each rule lists the target types that source may connect to under outputs:
const flow = createFlow({
nodes: [...],
edges: [...],
connectionRules: {
input: { outputs: ['process'] },
process: { outputs: ['process', 'output'] },
output: { outputs: [] },
},
})
// Check if a connection is valid
flow.isValidConnection({ source: '1', target: '2' }) // booleanCustom validation and drop snapping
const flow = createFlow({
// Vetoed before connectionRules — return false to refuse.
isValidConnection: (c) => c.source !== c.target,
// A drop within 20 screen pixels of a target handle connects to it
// (React Flow's connectionRadius); 0 requires a drop ON the handle.
connectionRadius: 20,
})Graph Queries
flow.getConnectedEdges('2') // FlowEdge[] — all edges touching the node
flow.getIncomers('2') // FlowNode<TData>[] — nodes with edges pointing TO this node
flow.getOutgoers('2') // FlowNode<TData>[] — nodes this node points to
flow.getChildNodes('group') // FlowNode<TData>[] — nodes whose parentId === 'group'
flow.getAbsolutePosition('2') // XYPosition — position accounting for parent offsets (cycle-safe)Search and Filter
flow.findNodes((n) => n.type === 'process') // FlowNode<TData>[] — by predicate
flow.searchNodes('start') // FlowNode<TData>[] — by data.label text (case-insensitive)Undo / Redo
flow.pushHistory() // snapshot the current nodes + edges onto the undo stack
flow.undo() // restore the previous snapshot
flow.redo() // re-apply an undone snapshotAutomatic checkpoints
Every structural mutation — addNode(s), removeNode(s), addEdge(s), removeEdge(s), setNodes, setEdges, updateNodeData, deleteSelected, paste, fromJSON, layout — records a checkpoint before it applies, and a node drag records one at pointerdown, so undo() works with no manual pushHistory() calls. A checkpoint is skipped when nothing changed since the previous one, so calling pushHistory() yourself right before a mutation never double-records. autoHistory: false in the config restores the manual model.
Copy / Paste
flow.copySelected() // copy selected nodes (and edges between them) to an internal clipboard
flow.paste() // paste with a { x: 50, y: 50 } offset, fresh ids, and select the result
flow.paste({ x: 20, y: 20 }) // custom offsetCollision Detection
flow.getOverlappingNodes('2') // FlowNode<TData>[] — nodes whose bounding box overlaps
flow.resolveCollisions('2') // push overlapping nodes apart (optional 2nd arg = spacing, default 10)Proximity Connect
// Find the nearest unconnected node within `threshold` px → a Connection or null
flow.getProximityConnection('1', 200) // Connection | nullNode Extent (drag boundaries)
flow.setNodeExtent([[0, 0], [1000, 800]]) // clamp all node drags within bounds
flow.setNodeExtent(null) // remove the boundary
flow.clampToExtent({ x: 1200, y: 50 }) // → XYPosition clamped to the current extentupdateNodePosition and drags automatically respect the active extent.
Serialization
// Export flow state as a JSON-serializable object
const json = flow.toJSON()
// { nodes: [...], edges: [...], viewport: { x, y, zoom } }
// Import flow state (clears selection; edges get ids/types backfilled)
flow.fromJSON(json)
flow.fromJSON({ nodes, edges }) // viewport optional — omit to keep the current onetoJSON() returns a structural deep clone, so the result is safe to mutate / persist without affecting the live graph.
Listeners
Every listener returns an unsubscribe function.
const off = flow.onConnect((connection) => {
console.log('Connected:', connection.source, '→', connection.target)
})
off() // unsubscribe
flow.onNodesChange((changes) => {
// changes: NodeChange[] — { type: 'position' | 'dimensions' | 'select' | 'remove', ... }
})
flow.onNodeClick((node) => { /* FlowNode<TData> */ })
flow.onEdgeClick((edge) => { /* FlowEdge */ })
flow.onNodeDragStart((node) => { /* drag begins */ })
flow.onNodeDragEnd((node) => { /* drag ends */ })
flow.onNodeDoubleClick((node) => { /* double click */ })Full listener surface
| Listener | Fires |
|---|---|
onNodesChange(changes) | position / dimensions / remove batches |
onEdgesChange(changes) | add / remove batches |
onSelectionChange({ nodes, edges }) | every selection write, with the live objects (not at creation) |
onViewportChange(viewport) | every viewport write, animation frames included |
onNodesDelete(nodes) / onEdgesDelete(edges) | what deleteSelected() / removeNode(s) / removeEdge(s) removed (an edge removed with its node is reported too) |
onNodeDragStart / onNodeDrag / onNodeDragEnd | drag lifecycle; onNodeDrag is per frame with the live node |
onConnectStart({ nodeId, handleId }) / onConnectEnd(connection | null) | a connection drag starting / ending (null = dropped nowhere) |
onConnect(connection) | an edge added by a drop or addEdge(s) |
onNodeClick / onNodeDoubleClick / onEdgeClick / onPaneClick(event) | clicks; the pane is the empty canvas |
Every registrar returns an unsubscribe; dispose() clears them all.
Batch Operations
flow.batch(() => {
flow.addNode({ id: '10', position: { x: 0, y: 0 }, data: { label: 'A' } })
flow.addNode({ id: '11', position: { x: 200, y: 0 }, data: { label: 'B' } })
flow.addEdge({ source: '10', target: '11' })
})
// One signal notification for all changes — one render pass instead of threeComponents
<Flow> is the container. Place <Background>, <MiniMap>, <Controls>, <Panel> (and <NodeResizer> / <NodeToolbar> inside custom nodes) as children.
<Flow>
The main container. fitView is a createFlow config option, not a <Flow> prop. <Flow> accepts instance (required), optional nodeTypes / edgeTypes renderer maps, plus style / class / children.
const flow = createFlow({ fitView: true, nodes, edges })
<Flow instance={flow} nodeTypes={{ custom: MyNode }} style="width: 100%; height: 600px;">
<Background />
<MiniMap />
<Controls />
</Flow><Background>
Decorative background pattern that moves with the viewport. variant is 'dots' (default), 'lines', or 'cross'. Other props: gap (default 20), size (default 1), color (default '#ddd').
<Background variant="dots" gap={20} size={1} />
<Background variant="lines" gap={20} color="#e5e7eb" />
<Background variant="cross" gap={20} /><MiniMap>
Scaled overview with a viewport indicator. Clicks recenter the main viewport.
<MiniMap
nodeColor={(node) => (node.type === 'input' ? '#6366f1' : '#94a3b8')}
maskColor="rgba(0,0,0,0.2)"
/>Props: nodeColor (string or (node) => string, default grey), maskColor, width, height, style, class.
<Controls>
Zoom in / zoom out / fit-view button cluster (plus a live zoom-% readout). All buttons are toggleable:
<Controls showFitView showZoomIn showZoomOut showLock position="bottom-left" />Props (all optional): showZoomIn (default true), showZoomOut (default true), showFitView (default true), showLock (default false), position (default 'bottom-left').
Overlay child order
Overlay order no longer matters on current Pyreon versions — <Controls>, <MiniMap>, <Background>, <Panel>, <NodeResizer>, and <NodeToolbar> can sit in any position:
<Flow instance={flow}>
<Background />
<Controls />
<MiniMap />
</Flow><Handle>
Connection point on a custom node — place inside a node renderer. type is 'source' or 'target', position is a Position enum value. Give multiple source/target handles distinct ids so edges can reference a specific one via sourceHandle / targetHandle, and give same-side siblings distinct offsets (percent along the side, default 50 = centered) so the dots don't overlap.
import { Handle, Position } from '@pyreon/flow'
function CustomNode(props: NodeComponentProps<MyData>) {
return (
<div class="custom-node">
<Handle type="target" position={Position.Left} />
<span>{() => props.data().label}</span>
<Handle type="source" position={Position.Right} id="out-a" offset={30} />
<Handle type="source" position={Position.Right} id="out-b" offset={70} />
<Handle type="source" position={Position.Bottom} id="out-fallback" />
</div>
)
}
// Reference a specific handle by id
flow.addEdge({ source: '1', sourceHandle: 'out-a', target: '2' })Edges anchor at the dot's real rendered position — the renderer measures every <Handle>'s center (see Edge Anchoring & Node Measurement), so restyling or repositioning a dot via style / class moves the edge attachment with it. An edge without a sourceHandle / targetHandle id uses the node's first handle of that type.
<Panel>
Positioned overlay for toolbars, legends, or action buttons. position is one of 'top-left' / 'top-right' / 'bottom-left' / 'bottom-right' (default 'top-left'). Pass any JSX as children.
<Panel position="top-right">
<button onClick={() => flow.fitView()}>Fit</button>
<button onClick={() => flow.toJSON()}>Export</button>
</Panel><NodeResizer>
Drag handles for resizing a node — place inside a custom node component. Requires both nodeId and the instance.
function ResizableNode(props: NodeComponentProps<MyData>) {
return (
<div style="min-width: 100px; min-height: 50px; position: relative;">
{() => props.data().label}
<NodeResizer nodeId={props.id} instance={flow} />
</div>
)
}Props: nodeId (required), instance (required FlowInstance), minWidth (default 50), minHeight (default 30), handleSize (default 8), showEdgeHandles (default false — corners only).
<NodeToolbar>
Floating toolbar near a node, shown when the node is selected. Pass the node's selected accessor so it shows/hides reactively.
function EditableNode(props: NodeComponentProps<MyData>) {
return (
<div class="node">
{() => props.data().label}
<NodeToolbar selected={props.selected} position="top">
<button>Edit</button>
<button>Delete</button>
</NodeToolbar>
</div>
)
}Props: position is 'top' / 'bottom' / 'left' / 'right' (a string, not a Position enum value; default 'top'), offset (default 8), showOnSelect (default true), selected (boolean or () => boolean), style, class, children.
HTML edge labels
SVG <text> cannot hold buttons or wrapped rich text. A custom edge wraps its label in <EdgeLabelRenderer>: the children are portaled into a <div> layer inside the viewport (they pan and zoom with the graph) and position themselves with the edge's labelX / labelY accessors.
import { EdgeLabelRenderer, getBezierPath, type EdgeComponentProps } from '@pyreon/flow'
function LabeledEdge(props: EdgeComponentProps) {
return (
<>
<path d={() => getBezierPath({ /* … */ }).path} style="fill: none; stroke: #999;" />
<EdgeLabelRenderer>
<div
class="nopan"
style={() =>
`position: absolute; pointer-events: all; transform: translate(-50%, -50%) translate(${props.labelX()}px, ${props.labelY()}px);`
}
>
<button onClick={() => flow.removeEdge(props.edge.id!)}>×</button>
</div>
</EdgeLabelRenderer>
</>
)
}The layer is pointer-events: none; an interactive label opts back in with pointer-events: all and the nopan class (so a click on it does not start a canvas pan).
Portaled node toolbar
Pass the node's id and <NodeToolbar> leaves the node: it renders in the canvas's toolbar layer, follows the node through pan and zoom without being scaled, is never clipped by the node's overflow, and sits above every node. position (top | bottom | left | right), offset and align (start | center | end) place it.
function EditableNode(props: NodeComponentProps) {
return (
<div class="node">
<NodeToolbar nodeId={props.id} selected={props.selected} position="top" align="end">
<button onClick={() => duplicate(props.id)}>Duplicate</button>
</NodeToolbar>
{props.data().label}
</div>
)
}Without nodeId the toolbar renders inline inside the node as before (and on the server, where there are no layers).
Minimap interaction
Dragging on <MiniMap> pans the graph and the wheel zooms it around the canvas center; a click still centers on the clicked point. pannable={false} / zoomable={false} turn either off.
Controls children
<Controls> accepts children rendered after the built-in buttons — a <button> picks up the control styling.
Theming
Every color the flow renderer emits goes through a --pyreon-flow-* CSS custom property with the historical light-mode value as fallback — zero setup for light apps, one CSS block to re-skin everything (dark mode, brand colors). Set them on the flow container or any ancestor:
.dark .pyreon-flow {
--pyreon-flow-node-bg: #1e293b;
--pyreon-flow-node-color: #e2e8f0;
--pyreon-flow-node-border: #334155;
--pyreon-flow-edge: #64748b;
--pyreon-flow-bg-pattern: #334155;
--pyreon-flow-accent: #818cf8;
}The full vocabulary:
| Variable | Fallback | Themes |
|---|---|---|
--pyreon-flow-accent | #3b82f6 | Selected edge stroke, connection line, snap helper lines, selection-box border, resizer border, handle hover, minimap viewport outline |
--pyreon-flow-accent-bg | rgba(59, 130, 246, 0.08) | Rubber-band selection-box fill |
--pyreon-flow-bg-pattern | #ddd | <Background> dots / lines / cross pattern |
--pyreon-flow-node-bg | #fff | Default node background |
--pyreon-flow-node-color | #1a192b | Default node text |
--pyreon-flow-node-border | #ddd | Default node border |
--pyreon-flow-node-selected | #3b82f6 | Default node border when selected |
--pyreon-flow-selection-glow | rgba(59, 130, 246, 0.3) | Selected node drop-shadow ring (flowStyles) |
--pyreon-flow-edge | #999 | Edge stroke + default arrowhead color |
--pyreon-flow-edge-label | #666 | Edge label text |
--pyreon-flow-handle-bg | #555 | <Handle> dot fill |
--pyreon-flow-handle-border | white | <Handle> dot border ring |
--pyreon-flow-handle-valid | #22c55e | Target-handle hover (valid drop) color (flowStyles) |
--pyreon-flow-control-color | #555 | <Controls> button glyphs |
--pyreon-flow-control-muted | #999 | <Controls> zoom-percent readout |
--pyreon-flow-controls-hover | #f3f4f6 | <Controls> button hover background (flowStyles) |
--pyreon-flow-controls-active | #e5e7eb | <Controls> button pressed background (flowStyles) |
--pyreon-flow-panel-bg | #fff | <Panel> / <Controls> surface background |
--pyreon-flow-panel-border | #ddd | <Panel> / <Controls> surface border |
--pyreon-flow-panel-shadow | rgba(0,0,0,0.08) | <Panel> / <Controls> drop shadow |
--pyreon-flow-toolbar-bg | white | <NodeToolbar> background |
--pyreon-flow-toolbar-border | #ddd | <NodeToolbar> border |
--pyreon-flow-resizer-bg | white | <NodeResizer> handle fill (border uses --pyreon-flow-accent) |
--pyreon-flow-minimap-node | #e2e8f0 | <MiniMap> node rectangles (per-node nodeColor prop wins) |
--pyreon-flow-minimap-mask | rgba(0, 0, 0, 0.08) | <MiniMap> outside-viewport mask (maskColor prop wins) |
Rows marked flowStyles live in the optional injected stylesheet (below); everything else is inline on the elements, so the variables work with no stylesheet injection at all. Per-element props (node.style, edge.style, nodeColor, maskColor, color on <Background>) always override the theme variables.
Color modes
<Flow colorMode="dark"> (or "system", which follows prefers-color-scheme) renders data-color-mode on the canvas, and flowStyles carries a dark value for every --pyreon-flow-* variable behind it — nodes, edges, panels, controls, minimap, toolbar, handles and the background pattern. Your own variable overrides on an ancestor still win, so a custom theme needs no changes.
flowStyles — hover & animation states
Inline styles can't express :hover / @keyframes, so those polish states ship as an exported CSS string. Inject it once at app root:
import { flowStyles } from '@pyreon/flow'
const style = document.createElement('style')
style.textContent = flowStyles
document.head.appendChild(style)It provides: the animated edge dash animation, node drag/selected shadows, handle + resizer hover scaling and colors, <Controls> button hover/active backgrounds, minimap hover opacity, and the toolbar enter animation. Everything renders fine without it — the graph just loses those hover/animation flourishes.
Edge Path Utilities
Pure functions for generating SVG edge paths. Each takes a params object and returns an EdgePathResult object — { path, labelX, labelY }.
import { getBezierPath, getSmoothStepPath, getStraightPath, getStepPath } from '@pyreon/flow'
const { path, labelX, labelY } = getBezierPath({
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: 200,
targetY: 100,
targetPosition: Position.Left,
})| Helper | Signature (params → result) |
|---|---|
getBezierPath | { sourceX, sourceY, sourcePosition?, targetX, targetY, targetPosition?, curvature? } → EdgePathResult |
getSmoothStepPath | { sourceX, sourceY, sourcePosition?, targetX, targetY, targetPosition?, borderRadius?, offset? } → EdgePathResult |
getStepPath | { sourceX, sourceY, sourcePosition?, targetX, targetY, targetPosition? } → EdgePathResult (smoothstep, borderRadius: 0) |
getStraightPath | { sourceX, sourceY, targetX, targetY } → EdgePathResult |
getWaypointPath | { sourceX, sourceY, targetX, targetY, waypoints } → EdgePathResult |
getEdgePath | (type, sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, pathOptions?) → EdgePathResult (dispatches by type, threads pathOptions) |
getHandlePosition | (position, nodeX, nodeY, nodeWidth, nodeHeight, handleId?) → XYPosition |
getSmartHandlePositions | (sourceNode, targetNode, dims?) → { sourcePosition, targetPosition } (auto-picks nearest edges) |
getFloatingEndpoints | (sourceNode, targetNode, dims) → { source, target } perimeter points + sides (the natural-angle floating endpoints) |
getNodeIntersection | (box, toward) → XYPosition where the ray from box center toward a point exits the box perimeter |
resolveHandleAnchor | (node, handleId, type, dims, measurement?) → { x, y, position } | null (handle-anchor priority chain) |
getEffectiveDimensions | (node, measurement?) → Dimensions (explicit → measured → DEFAULT_NODE_WIDTH×DEFAULT_NODE_HEIGHT) |
Marker Helpers
Low-level helpers for working with edge markers (also pure + exported):
| Helper | Signature | Description |
|---|---|---|
resolveMarker | (spec) => EdgeMarker | null | Normalize a bare MarkerType / object / null to a fully-defaulted marker |
markerId | (marker) => string | Deterministic DOM-safe <defs> id for a marker (identical configs collapse) |
resolveEdgeMarkers | (edge, defaultMarkerEnd) => { start, end } | Resolve an edge's start/end markers, applying the flow default end |
collectEdgeMarkers | (edges, defaultMarkerEnd) => Map<string, EdgeMarker> | Every distinct marker across all edges (the deduped <defs> set) |
DEFAULT_MARKER_END | EdgeMarker | The built-in default { type: MarkerType.ArrowClosed } |
Position Enum
import { Position } from '@pyreon/flow'
Position.Top // 'top'
Position.Right // 'right'
Position.Bottom // 'bottom'
Position.Left // 'left'Accessibility
<Flow> follows the React Flow keyboard model. The canvas is a focusable role="group" named by ariaLabel (default "Flow diagram"); every node and edge is a focus stop in DOM order after it.
| Key | On a node | On an edge | On the canvas |
|---|---|---|---|
Tab / Shift+Tab | next / previous node or edge | same | leaves the canvas |
Enter / Space | select (Shift adds to the selection) | select | — |
← → ↑ ↓ | move the node 10 units (Shift: 100), selecting it first; one undo entry per key press | — | — |
Delete / Backspace | delete the selection | delete the selection | delete the selection |
Escape | clear the selection | clear the selection | clear the selection |
Cmd/Ctrl+A · C · V · Z · Shift+Z | select all · copy · paste · undo · redo (bubble up from any focus stop) |
What a screen reader gets:
Nodes carry
role="group"andaria-roledescription="node"; the node's own content is its name unless you setariaLabel. Edges arerole="button"named"Edge from <source> to <target>"(orariaLabel).Both point
aria-describedbyat a visually-hidden instruction text inside the canvas, so the keys above are announced on focus.A polite live region announces selection changes ("2 nodes and 1 edge selected", "Selection cleared") and keyboard moves ("Moved node a to 10, 0").
The background pattern and helper lines are
aria-hidden; the edge layer is a namedgroupso its focusable paths stay in the accessibility tree.
Focus is visible only for keyboard users: flowStyles themes :focus-visible on the canvas, nodes and edges with --pyreon-flow-accent (a pointer click draws no ring). The stylesheet also disables the package's transitions and the animated-edge dash under prefers-reduced-motion: reduce, and fitView() / animateViewport() / animated layout() jump straight to their target under that setting — override with reducedMotion: false | true in the config.
Opt-outs: focusable: false on a node or edge, nodesFocusable: false / edgesFocusable: false for the default, and disableKeyboardA11y: true to drop nodes and edges from the tab order entirely (the canvas keeps its shortcuts).
SSR and hydration
<Flow> renders on the server. Nothing in the package touches window, document, ResizeObserver or requestAnimationFrame at setup — every browser API sits behind a typeof guard or inside a ref/onMount, so renderToString produces the container, the node layer with every node at its declared position, and the edge layer with paths built from explicit width/height or the 150×40 default (measured sizes only exist on the client). On hydration the client adopts that DOM, the shared ResizeObserver measures the real node boxes, and edge geometry re-anchors to them — a node that renders larger than its declared size shifts its edge endpoints once, on the first measurement.
Two things to know:
fitView: trueruns against the default 800×600 container on the server and again on the client's first real measurement, so an SSR page can paint one frame at the default viewport before fitting. Pass an explicitviewport(orwidth/heighton the nodes) to make the server frame final.Layout is client-only by default.
flow.layout()code-splits the engine behind a dynamic import; call it inonMount(or run it at build time and ship the positions) rather than during render.
Cleanup
flow.dispose() // cancel in-flight animations + clear all listenersuseFlow calls this automatically on unmount. For a createFlow instance owned outside a component tree, call it yourself at the right lifecycle point.
API Reference
Core functions
| Function | Signature | Description |
|---|---|---|
createFlow<TData>(config?) | (config?: FlowConfig<TData>) => FlowInstance<TData> | Create a reactive flow instance |
useFlow<TData>(config) | (config: FlowConfig<TData>) => FlowInstance<TData> | createFlow + auto-dispose on unmount (component-scoped) |
computeLayout<TData>(...) | (nodes, edges, algorithm?, options?) => Promise<Array<{ id, position }>> | Standalone layout through the built-in engine (code-split); doesn't mutate a flow |
FlowInstance — state
| Member | Type | Description |
|---|---|---|
nodes | Signal<FlowNode<TData>[]> | All nodes (reactive) |
edges | Signal<FlowEdge[]> | All edges (reactive) |
viewport | Signal<Viewport> | { x, y, zoom } (reactive) |
containerSize | Signal<{ width, height }> | Set by <Flow> via ResizeObserver |
zoom | Computed<number> | Current zoom level |
selectedNodes | Computed<string[]> | Selected node ids |
selectedEdges | Computed<string[]> | Selected edge ids |
nodeMap | Computed<Map<string, FlowNode>> | O(1) node lookup (rebuilt per nodes() change) |
edgeMap | Computed<Map<string, FlowEdge>> | O(1) edge lookup (rebuilt per edges() change) |
measurements | Signal<Map<string, NodeMeasurement>> | Measured node boxes + <Handle> dot centers (see Edge Anchoring) |
config | FlowConfig<TData> | The config the flow was created with |
FlowInstance — methods
| Method | Returns | Description |
|---|---|---|
getNode(id) / getEdge(id) | FlowNode | undefined / FlowEdge | undefined | Lookup by id |
addNode(node) / addEdge(edge) | void | Add (edge id auto-generated if absent; dupes skipped) |
removeNode(id) / removeEdge(id) | void | Remove (removeNode also removes its edges) |
updateNode(id, partial) | void | Shallow-merge properties onto a node |
updateNodePosition(id, pos) | void | Move a node (respects snap + extent) |
reconnectEdge(id, { source?, target?, ... }) | void | Reconnect an edge's endpoints/handles |
addEdgeWaypoint / updateEdgeWaypoint / removeEdgeWaypoint | void | Edge bend-point ops |
isValidConnection(connection) | boolean | Check against connectionRules |
selectNode(id, additive?) / selectEdge(id, additive?) | void | Select (positional additive boolean) |
deselectNode(id) / clearSelection() / selectAll() | void | Selection ops |
deleteSelected() / moveSelectedNodes(dx, dy) | void | Operate on the current selection |
fitView(ids?, padding?) | void | Fit all (or given) nodes |
zoomTo(z) / zoomIn() / zoomOut() | void | Zoom (clamped to min/max) |
panTo(pos) / focusNode(id, zoom?) / animateViewport(target, ms?) | void | Move the viewport |
isNodeVisible(id) | boolean | Is the node within the viewport |
getNodeDimensions(id) | Dimensions | Effective node box: explicit → measured → 150×40 ({0,0} for unknown id) |
layout(algorithm?, options?) | Promise<void> | Auto-layout via the built-in engine |
batch(fn) | void | Coalesce mutations into one notification |
getConnectedEdges(id) | FlowEdge[] | Edges touching a node |
getIncomers(id) / getOutgoers(id) | FlowNode[] | Upstream / downstream nodes |
getChildNodes(id) / getAbsolutePosition(id) | FlowNode[] / XYPosition | Group/sub-flow helpers (cycle-safe) |
findNodes(pred) / searchNodes(query) | FlowNode[] | Filter by predicate / by data.label text |
getProximityConnection(id, threshold?) | Connection | null | Nearest valid unconnected node |
getOverlappingNodes(id) / resolveCollisions(id, spacing?) | FlowNode[] / void | Collision detection / resolution |
setNodeExtent(extent) / clampToExtent(pos, w?, h?) | void / XYPosition | Drag boundaries |
copySelected() / paste(offset?) | void | Clipboard ops |
pushHistory() / undo() / redo() | void | Manual undo/redo (50-snapshot cap) |
getSnapLines(id, pos, threshold?) | { x, y, snappedPosition } | Helper-line snap targets for a dragged node |
toJSON() / fromJSON(data) | { nodes, edges, viewport } / void | Serialize / restore |
onConnect / onNodesChange / onNodeClick / onEdgeClick / onNodeDragStart / onNodeDragEnd / onNodeDoubleClick | () => void | Subscribe; returns unsubscribe |
dispose() | void | Cancel animations + clear listeners |
Components
| Component | Key props |
|---|---|
<Flow> | instance (required), nodeTypes?, edgeTypes?, style?, class?, children? |
<Background> | variant? ('dots'/'lines'/'cross'), gap?, size?, color? |
<MiniMap> | nodeColor?, maskColor?, width?, height?, style?, class? |
<Controls> | showZoomIn?, showZoomOut?, showFitView?, showLock?, position? |
<Panel> | position?, style?, class?, children |
<Handle> | type ('source'/'target'), position (Position), id?, style?, class? |
<NodeResizer> | nodeId (required), instance (required), minWidth?, minHeight?, handleSize?, showEdgeHandles? |
<NodeToolbar> | position? ('top'/'bottom'/'left'/'right'), offset?, showOnSelect?, selected?, style?, class?, children |
NodeComponentProps<TData> (custom node renderers)
| Prop | Type | Notes |
|---|---|---|
id | string | Stable identity — plain value, never changes |
data | () => TData | Reactive accessor — read inside a reactive scope |
selected | () => boolean | Reactive accessor |
dragging | () => boolean | Reactive accessor |
Comparison with React Flow
| Feature | React Flow | @pyreon/flow |
|---|---|---|
| Update 1 of 1000 nodes | New array → diff all | 1 signal → 1 DOM update |
| Node re-render on drag | Re-render of affected nodes | Patch in place — node mounts once |
| Dependencies | React + D3 | No D3, no layout dependency — the seven-mode engine is built in and code-split |
| State management | 3 callbacks + applyChanges | Automatic — zero boilerplate |
| Auto-layout | Separate elkjs/dagre setup | flow.layout('layered') — seven modes built in |
| Undo/redo | DIY | Built-in, automatic checkpoints on every mutation |
| Connection rules | isValidConnection callback | Declarative connectionRules AND an isValidConnection veto |