pyreon

@pyreon/flow provides reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom without D3, auto-layout via lazy-loaded elkjs, 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 elkjs-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

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

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

Auto-Layout

The playground above has live layered, tree, and force buttons — click them to watch the layout animate.

Layout nodes automatically using elkjs (lazy-loaded — the elkjs chunk is only fetched when flow.layout() is first called):

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

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

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 */ })

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.

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.

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'

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 elkjs layout (lazy-loaded); 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 elkjs
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; elkjs lazy-loaded on first layout()
State management3 callbacks + applyChangesAutomatic — zero boilerplate
Auto-layoutSeparate elkjs setupflow.layout('layered')
Undo/redoDIYBuilt-in (manual pushHistory)
Connection rulesisValidConnection callbackDeclarative config
Flow