pyreon

@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.

@pyreon/flowstable

Installation

npm install @pyreon/flow @pyreon/runtime-dom
bun add @pyreon/flow @pyreon/runtime-dom
pnpm add @pyreon/flow @pyreon/runtime-dom
yarn add @pyreon/flow @pyreon/runtime-dom

Quick 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.

Node graph — drag, select, connect

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.

Flow playground — arrows + auto-layout + every overlay

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

OptionTypeDefaultDescription
nodesFlowNode<TData>[][]Initial nodes
edgesFlowEdge[][]Initial edges
defaultEdgeTypeEdgeType'bezier'Edge type applied when an edge omits type
defaultEdgeOptionsDefaultEdgeOptionsDefaults merged into every edge that doesn't set the field itself (see Default Edge Options)
minZoomnumber0.1Minimum zoom level
maxZoomnumber4Maximum zoom level
snapToGridbooleanfalseSnap node positions to a grid
snapGridnumber15Grid size in pixels
snapToObjectsbooleantrueAlign-to-other-nodes helper lines on drag (see Object Snapping)
connectionRulesConnectionRuleValidate connections by source node type (see Connection Rules)
nodesDraggablebooleantrueWhether nodes are draggable by default
nodesConnectablebooleantrueWhether nodes are connectable by default
nodesSelectablebooleantrueWhether nodes are selectable by default
multiSelectbooleantrueAllow multi-selection
nodeExtent[[minX, minY], [maxX, maxY]]Constrain node positions within bounds
pannablebooleantrueWhether panning is enabled
zoomablebooleantrueWhether zooming is enabled
fitViewbooleanfalseFit all nodes in the viewport on initial render
fitViewPaddingnumber0.1Padding ratio used by fitView()
defaultMarkerEndEdgeMarkerSpec | null{ type: ArrowClosed }Default END arrowhead; null makes edges arrowless
onlyRenderVisibleElementsbooleanfalseCull 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() change

Custom 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>
PropTypeDescription
idstringStable node identity — never changes; the keyed identity, plain string
data() => TDataAccessor for the node's current data (reflects updateNode mutations)
selected() => booleanAccessor — whether the node is selected
dragging() => booleanAccessor — 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> | undefined

Batch 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):

TypeDescription
bezierSmooth cubic bezier curve (default)
smoothstepRight-angle path with rounded corners
stepRight-angle path with sharp corners
straightDirect 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 } })
FieldApplies toDefaultDescription
curvaturebezier0.25Control-point strength as a fraction of endpoint distance
borderRadiussmoothstep5Corner rounding radius in px (step locks this to 0)
offsetsmoothstep, step20Straight 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.typedefaultEdgeOptions.typedefaultEdgeType'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 arrowless

Set 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:

  1. A measured <Handle> dot. The renderer measures every node's real DOM box and every <Handle> dot's center (client-only, via a per-node ResizeObserver). edge.sourceHandle / edge.targetHandle anchor 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).

  2. A config handle. A handle declared in node.sourceHandles / node.targetHandles that hasn't been measured yet (first frame, SSR) anchors at its declared side's midpoint.

  3. 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×40 default

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/dy

Viewport

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 viewport

Render 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 node

A 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

OptionTypeDefaultDescription
direction'UP' | 'DOWN' | 'LEFT' | 'RIGHT''DOWN'Layout direction
nodeSpacingnumber50Spacing between nodes
layerSpacingnumber80Spacing between layers
edgeRouting'orthogonal' | 'splines' | 'polyline''orthogonal'How edges are routed
animatebooleantrueAnimate the layout transition
animationDurationnumber300Animation 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 ):

Optionlayeredtreeforcestressradialboxrectpacking
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' }) // boolean

Custom 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 snapshot

Automatic 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 offset

Collision 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 | null

Node 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 extent

updateNodePosition 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 one

toJSON() 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

ListenerFires
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 / onNodeDragEnddrag 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 three

Components

<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:

VariableFallbackThemes
--pyreon-flow-accent#3b82f6Selected edge stroke, connection line, snap helper lines, selection-box border, resizer border, handle hover, minimap viewport outline
--pyreon-flow-accent-bgrgba(59, 130, 246, 0.08)Rubber-band selection-box fill
--pyreon-flow-bg-pattern#ddd<Background> dots / lines / cross pattern
--pyreon-flow-node-bg#fffDefault node background
--pyreon-flow-node-color#1a192bDefault node text
--pyreon-flow-node-border#dddDefault node border
--pyreon-flow-node-selected#3b82f6Default node border when selected
--pyreon-flow-selection-glowrgba(59, 130, 246, 0.3)Selected node drop-shadow ring (flowStyles)
--pyreon-flow-edge#999Edge stroke + default arrowhead color
--pyreon-flow-edge-label#666Edge label text
--pyreon-flow-handle-bg#555<Handle> dot fill
--pyreon-flow-handle-borderwhite<Handle> dot border ring
--pyreon-flow-handle-valid#22c55eTarget-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-shadowrgba(0,0,0,0.08)<Panel> / <Controls> drop shadow
--pyreon-flow-toolbar-bgwhite<NodeToolbar> background
--pyreon-flow-toolbar-border#ddd<NodeToolbar> border
--pyreon-flow-resizer-bgwhite<NodeResizer> handle fill (border uses --pyreon-flow-accent)
--pyreon-flow-minimap-node#e2e8f0<MiniMap> node rectangles (per-node nodeColor prop wins)
--pyreon-flow-minimap-maskrgba(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,
})
HelperSignature (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):

HelperSignatureDescription
resolveMarker(spec) => EdgeMarker | nullNormalize a bare MarkerType / object / null to a fully-defaulted marker
markerId(marker) => stringDeterministic 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_ENDEdgeMarkerThe 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.

KeyOn a nodeOn an edgeOn the canvas
Tab / Shift+Tabnext / previous node or edgesameleaves the canvas
Enter / Spaceselect (Shift adds to the selection)select
move the node 10 units (Shift: 100), selecting it first; one undo entry per key press
Delete / Backspacedelete the selectiondelete the selectiondelete the selection
Escapeclear the selectionclear the selectionclear the selection
Cmd/Ctrl+A · C · V · Z · Shift+Zselect all · copy · paste · undo · redo (bubble up from any focus stop)

What a screen reader gets:

  • Nodes carry role="group" and aria-roledescription="node"; the node's own content is its name unless you set ariaLabel. Edges are role="button" named "Edge from <source> to <target>" (or ariaLabel).

  • Both point aria-describedby at 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 named group so 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: true runs 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 explicit viewport (or width/height on 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 in onMount (or run it at build time and ship the positions) rather than during render.

Cleanup

flow.dispose() // cancel in-flight animations + clear all listeners

useFlow 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

FunctionSignatureDescription
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

MemberTypeDescription
nodesSignal<FlowNode<TData>[]>All nodes (reactive)
edgesSignal<FlowEdge[]>All edges (reactive)
viewportSignal<Viewport>{ x, y, zoom } (reactive)
containerSizeSignal<{ width, height }>Set by <Flow> via ResizeObserver
zoomComputed<number>Current zoom level
selectedNodesComputed<string[]>Selected node ids
selectedEdgesComputed<string[]>Selected edge ids
nodeMapComputed<Map<string, FlowNode>>O(1) node lookup (rebuilt per nodes() change)
edgeMapComputed<Map<string, FlowEdge>>O(1) edge lookup (rebuilt per edges() change)
measurementsSignal<Map<string, NodeMeasurement>>Measured node boxes + <Handle> dot centers (see Edge Anchoring)
configFlowConfig<TData>The config the flow was created with

FlowInstance — methods

MethodReturnsDescription
getNode(id) / getEdge(id)FlowNode | undefined / FlowEdge | undefinedLookup by id
addNode(node) / addEdge(edge)voidAdd (edge id auto-generated if absent; dupes skipped)
removeNode(id) / removeEdge(id)voidRemove (removeNode also removes its edges)
updateNode(id, partial)voidShallow-merge properties onto a node
updateNodePosition(id, pos)voidMove a node (respects snap + extent)
reconnectEdge(id, { source?, target?, ... })voidReconnect an edge's endpoints/handles
addEdgeWaypoint / updateEdgeWaypoint / removeEdgeWaypointvoidEdge bend-point ops
isValidConnection(connection)booleanCheck against connectionRules
selectNode(id, additive?) / selectEdge(id, additive?)voidSelect (positional additive boolean)
deselectNode(id) / clearSelection() / selectAll()voidSelection ops
deleteSelected() / moveSelectedNodes(dx, dy)voidOperate on the current selection
fitView(ids?, padding?)voidFit all (or given) nodes
zoomTo(z) / zoomIn() / zoomOut()voidZoom (clamped to min/max)
panTo(pos) / focusNode(id, zoom?) / animateViewport(target, ms?)voidMove the viewport
isNodeVisible(id)booleanIs the node within the viewport
getNodeDimensions(id)DimensionsEffective 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)voidCoalesce mutations into one notification
getConnectedEdges(id)FlowEdge[]Edges touching a node
getIncomers(id) / getOutgoers(id)FlowNode[]Upstream / downstream nodes
getChildNodes(id) / getAbsolutePosition(id)FlowNode[] / XYPositionGroup/sub-flow helpers (cycle-safe)
findNodes(pred) / searchNodes(query)FlowNode[]Filter by predicate / by data.label text
getProximityConnection(id, threshold?)Connection | nullNearest valid unconnected node
getOverlappingNodes(id) / resolveCollisions(id, spacing?)FlowNode[] / voidCollision detection / resolution
setNodeExtent(extent) / clampToExtent(pos, w?, h?)void / XYPositionDrag boundaries
copySelected() / paste(offset?)voidClipboard ops
pushHistory() / undo() / redo()voidManual 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 } / voidSerialize / restore
onConnect / onNodesChange / onNodeClick / onEdgeClick / onNodeDragStart / onNodeDragEnd / onNodeDoubleClick() => voidSubscribe; returns unsubscribe
dispose()voidCancel animations + clear listeners

Components

ComponentKey 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)

PropTypeNotes
idstringStable identity — plain value, never changes
data() => TDataReactive accessor — read inside a reactive scope
selected() => booleanReactive accessor
dragging() => booleanReactive accessor

Comparison with React Flow

FeatureReact Flow@pyreon/flow
Update 1 of 1000 nodesNew array → diff all1 signal → 1 DOM update
Node re-render on dragRe-render of affected nodesPatch in place — node mounts once
DependenciesReact + D3No D3, no layout dependency — the seven-mode engine is built in and code-split
State management3 callbacks + applyChangesAutomatic — zero boilerplate
Auto-layoutSeparate elkjs/dagre setupflow.layout('layered') — seven modes built in
Undo/redoDIYBuilt-in, automatic checkpoints on every mutation
Connection rulesisValidConnection callbackDeclarative connectionRules AND an isValidConnection veto
Flow