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

// @check
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
defaultEdgeOptionsDefaultEdgeOptions—Defaults 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)
connectionRulesConnectionRule—Validate 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
historyLimitnumber50Undo depth; the oldest checkpoint is dropped past it. Read at each checkpoint, so writing flow.config.historyLimit applies from the next one
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)
connectionMode'strict' | 'loose''strict'strict: a connection runs from a source handle to a target handle. loose: any handle to any handle (see Connection mode)
elevateNodesOnSelectbooleantrueDraw a selected node above the others (see Stacking order)
elevateEdgesOnSelectbooleanfalseDraw a selected edge above the others
autoPanOnNodeDragbooleantruePan the canvas when a dragged node nears its edge (see Auto-pan)
autoPanOnConnectbooleantruePan the canvas when a connection drag nears its edge
autoPanSpeednumber15Auto-pan speed, in screen pixels per frame at the edge

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.

Stacking order (zIndex)

Nodes and edges take an optional zIndex; a higher value draws above a lower one, and ties keep array order. A selected node is raised by 100 while elevateNodesOnSelect is on (the default) and a dragged node by 1000, so the node you are working with is not hidden behind a neighbour. elevateEdgesOnSelect raises a selected edge by 1000 and is off by default. These follow React Flow.

const flow = createFlow({
  nodes: [
    { id: 'frame', position: { x: 0, y: 0 }, data: {}, zIndex: -1 },
    { id: 'card', position: { x: 40, y: 40 }, data: {}, zIndex: 5 },
  ],
  edges: [{ id: 'e', source: 'frame', target: 'card', zIndex: 2 }],
  elevateEdgesOnSelect: true,
})

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.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 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' })

Connection mode

connectionMode: 'strict' (the default) only connects a source handle to a target handle, and a drag may start from either end: starting from a target handle builds the edge in the right direction. 'loose' connects any handle to any other, which suits undirected graphs; the edge runs from the handle the drag started on. A reconnect follows the same rule for the end being moved.

Auto-pan

When a dragged node, or a connection being drawn, comes within 40 screen pixels of the canvas edge, the canvas pans towards it, faster the closer the pointer gets, up to autoPanSpeed pixels per frame. The dragged node stays under the pointer while the view moves. Turn it off with autoPanOnNodeDrag: false / autoPanOnConnect: false.

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)

// Intersections — React Flow's helpers, in flow coordinates
flow.getIntersectingNodes('2')                  // FlowNode[] overlapping node 2 (hidden nodes skipped)
flow.getIntersectingNodes({ x: 0, y: 0, width: 100, height: 100 }, false) // fully INSIDE the rect
flow.isNodeIntersecting('2', { x: 0, y: 0, width: 100, height: 100 })     // boolean
flow.getNodesBounds(['1', '2'])                 // Rect — the box around the given nodes

partially (default true) counts any overlap; false requires the whole node to lie inside the other rect. Touching edges do not count as an overlap. Sizes come from explicit width/height, then the measured size, then the default. A node is never reported as intersecting itself.

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
onNodeContextMenu(node) / onEdgeContextMenu(edge) / onPaneContextMenu(position)a right-click (a long-press on iOS and Android). position is in flow coordinates. While any context-menu listener is registered the browser menu is suppressed for that target; with none, the browser's own menu shows
onNodeMouseEnter / onNodeMouseLeave / onEdgeMouseEnter / onEdgeMouseLeavepointer hover. On iOS and Android this needs a pointer (trackpad, mouse); touch has no hover

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

Edge building blocks

<BaseEdge>, <EdgeText> and <ViewportPortal> are React Flow's building blocks for custom edges.

  • <BaseEdge path label labelX labelY> draws an edge's visible stroke (the palette's edge colour at 1.5px unless you pass a style string) and an optional label. The flow already adds the wider invisible hit path around every edge.

  • <EdgeText x y label> draws a label in flow coordinates in the built-in edge-label style.

  • <ViewportPortal> renders arbitrary HTML in flow coordinates: it pans and zooms with the graph and does not scale with each node.

import { BaseEdge, getBezierPath, type EdgeComponentProps } from '@pyreon/flow'

function Wire(props: EdgeComponentProps) {
  const edge = () => getBezierPath({ sourceX: props.sourceX(), sourceY: props.sourceY(), targetX: props.targetX(), targetY: props.targetY() })
  return <BaseEdge path={edge().path} label="wire" labelX={edge().labelX} labelY={edge().labelY} />
}

A style replaces the default stroke entirely, as on any SVG element: a style that sets no stroke draws no line.

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

With no colorMode, a flow takes the app's colour mode — <PyreonUI mode> or <ColorModeProvider mode> from @pyreon/core — and is light when the app set none. <Flow colorMode="dark"> (or "system", which follows prefers-color-scheme) overrides it and 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.

iOS and Android

The same @pyreon/flow source compiles to a native flow editor on iOS (SwiftUI) and Android (Jetpack Compose). It is a native view tree, not a web page in a WebView.

What renders natively

WebNative
createFlow(config) / useFlow(config)PyreonFlowState, an observable engine with the same state and the same FlowInstance methods
<Flow instance={flow}>PyreonFlowView, with <Background>, <Controls>, <MiniMap>, <Panel>, <Handle>, <NodeResizer>, <NodeToolbar>, <EdgeLabelRenderer>, <BaseEdge> and <EdgeText>
A node without a typeThe web's default node: the same box, palette colours and selected border
nodeTypes / edgeTypesCustom node and edge components. The map must be statically resolvable.
<path d=…> in a custom edge or connection lineA native path. Any SVG path data works, including a template literal; fill, stroke and width follow the browser's rules.
Inline <svg> in a node, edge or connection-line rendererA native canvas. path, rect (rounded too), circle, ellipse, line, polyline, polygon and nested <g> draw scaled by the viewBox (xMidYMid meet, or preserveAspectRatio="none"), with fill / stroke / stroke-width inherited as in SVG. Dynamic attributes stay live.
<div> / <p> / <span> in a rendererLowered in the two shapes whose layout matches the browser's: text-only content becomes a text run, and a <div> of block children (or one child) becomes a flush-left stack with no gap.
connectionLineA custom connection line, shown only while a connection is being dragged
colorMode'light', 'dark' and 'system', using the web palette's colours
The seven layout algorithmsNative ports of the built-in layout engine

Gestures:

  • Drag, pan and pinch-zoom.

  • Connect and reconnect.

  • Resize.

  • Tap to select a node or an edge.

  • Long-press for the context-menu listeners, and pointer hover (trackpad or mouse) for the hover listeners.

  • Auto-pan while dragging a node or a connection near the edge.

  • connectionMode, zIndex and the select-elevation options behave as on the web.

Keyboard:

  • Arrow keys move the focused node. Hold Shift for larger steps.

  • Enter or Space selects the focused node or edge.

  • Escape clears the selection.

  • The delete keys remove the selection.

  • Cmd/Ctrl + A / C / V / Z select all, copy, paste and undo. Add Shift to Z to redo.

onlyRenderVisibleElements culls off-screen nodes on native too.

Animation frames (animateViewport, fitView, an animated layout) reach your listeners on the main thread on both platforms. On Android the flow view arranges this the first time it renders. Hand-written Kotlin that animates a PyreonFlowState before any flow view exists should call pyreonFlowUseMainThreadFrames() first.

What does not cross, and what to use instead

A few parts of the web package are tied to the DOM, and the compiler names each one when it meets it:

  • FlowLayersContext and flowStyles, the DOM renderer's layer context and CSS custom properties.

  • <ViewportPortal>, which places arbitrary HTML with CSS. Use <EdgeLabelRenderer> in a custom edge, <NodeToolbar>, or <Panel>.

  • markerStart / markerEnd on <BaseEdge> (an SVG url(#…) reference). Set the marker on the edge itself; a marker spec lowers natively.

  • A renderer whose look depends on CSS: DOM elements with a class or style, text mixed with inline elements, or browser CSS selectors. The compiler cannot see a stylesheet, and guessing a layout would draw something plausible and wrong.

  • SVG <text>, gradients, transform, and SVG children produced by a .map or a conditional inside an <svg>.

  • A renderer map computed at runtime, rather than one the compiler can resolve statically.

Native rendering matches the web in structure, colour, geometry and behaviour, but it is not pixel-identical and cannot be: text is set in the platform's system font with its own metrics, and each platform antialiases edges its own way. If a diagram must look exactly like the browser, down to the pixel, or needs any of the items above, host the unchanged web renderer with@pyreon/flow/webview:

import { FlowWebView } from '@pyreon/flow/webview'

<FlowWebView graph={graph} onSelect={(e) => select(e.id)} />

That is a WebView. It has the same costs as any hosted web content:

  • Start-up time.

  • Every update crosses a JSON bridge.

  • The diagram is opaque to native gestures and the platform accessibility tree.

Give it an explicit height where you can. Without one, it defaults to 150 points (iOS) or 150dp (Android), so it never collapses to zero.

FlowWebViewProps:

PropTypeDescription
graphFlowWebViewGraph | (() => FlowWebViewGraph){ nodes, edges }, the same JSON model <Flow> renders. A changed value is pushed into the page without a reload.
commandsFlowHostCommand[] | (() => FlowHostCommand[])Once-only viewport commands: { id, type: 'fit-view' } or { id, type: 'set-viewport', x, y, zoom }. An id never runs twice.
onSelect(payload: FlowSelectPayload) => voidNode tap: { id, data? }.
onEvent(event: FlowHostEvent) => voidTyped events: node-select, edge-select, viewport-change.
onMessage(payload: unknown) => voidEvery reverse-bridge payload (for a custom host page).
onError(error: Error) => voidErrors raised while the hosted renderer starts or updates.
htmlstringYour own host page (e.g. a bundled full @pyreon/flow app). Omit for the built-in renderer.
nodeWidth / nodeHeight / nodeFill / nodeStroke / labelColor / edgeColor / backgroundBuilt-in renderer styling (defaults 150 / 44 / #ffffff / #c9ced6 / #1f2933 / #98a2b3 / transparent).

buildFlowHostHtml(options?: BuildFlowHostHtmlOptions) returns the built-in host page as a string (the same styling options), for building it once and passing it as html. Types: FlowWebViewGraph, FlowWebViewNode, FlowWebViewEdge, FlowHostCommand, FlowHostEvent, FlowSelectPayload, FlowWebViewProps, BuildFlowHostHtmlOptions.

How this is verified

  • State and algorithms. Native test fixtures replay shared scenarios against both native engines, with the web engine's own answers as the oracle. Two checks fail when a portable method, or a native config field, has no scenario and no stated reason.

  • Gestures added for React Flow parity. On both device suites, a long-press on a node reaches onNodeContextMenu; a drag that starts on a target handle connects under connectionMode: 'strict', even when that node is raised; a tap where two nodes overlap reaches the one with the higher zIndex; and holding a dragged node at the canvas edge auto-pans. Hover is asserted on Android only, because XCUITest cannot synthesise pointer hover on iOS.

  • Rendering and interaction. The iOS Simulator and Android Emulator suites for the example apps read what the renderer painted or placed. They check marker colours, dark-mode canvas pixels, the default node's colours, a custom edge drawn from a path string (ending where the target node is), a custom node's inline <svg> (measured at its 16-point size from an 8-unit viewBox), panel placement and the custom connection line. They also drive the gestures and keyboard commands above.

  • Theme. A test requires every native palette colour to equal the web's --pyreon-flow-* value, in light and dark mode.

  • Scale. A 400-node graph with culling mounts at most 40 nodes on web, iOS and Android. It keeps those bounds while panning, dragging and pinching. On web, garbage-collection tests check that a disposed flow and its removed nodes are actually released.

How the platform-specific gaps in the test tooling are covered:

  • XCUITest cannot send Return, Escape or Delete to an iOS simulator app. On iOS, the native test suite drives those keys through the same function the view's key handler calls. Android asserts them on the device.

  • iPhone 16-family simulators never pass an appearance change to the app. On iOS, the native test suite renders the real flow view under a light and a dark colour scheme and checks that colorMode="system" follows it. Newer simulators and Android also check it on the device.

State that lives only in memory, flow graphs included, is lost when the app is closed: after a page reload on web, and when the system ends the app process on iOS or Android. It is kept across rotation and dark-mode switches on Android. To keep a graph across launches, save flow.toJSON() with @pyreon/storage and restore it with flow.fromJSON(...) on every target.

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?, options?)voidFit all (or given) nodes; options.duration animates
zoomTo(z, options?) / zoomIn() / zoomOut()voidZoom (clamped to min/max); options.duration animates
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 (historyLimit snapshots, default 50)
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