@pyreon/flow — API Reference
Generated from
flow'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see flow.
Reactive flow diagrams for Pyreon. Signal-native nodes and edges, pan/zoom via pointer events + CSS transforms, auto-layout from a built-in engine — no elkjs, no D3, no layout dependency at all, and deterministic (the same graph always yields the same positions). Each node mounts exactly once across the lifetime of the graph; drags and selection patches are O(1) via per-node reactive accessors, so a 60fps drag in a 1000-node graph stays cheap.
Peer dependencies:
@pyreon/runtime-dom— install alongside this package.
Multiplatform
Tier: Service backend — the API is shared; the native runtimes host it
one API with a web engine and a Swift/Kotlin port: createFlow/useFlow, the whole FlowInstance surface and all seven layouts lower to PyreonFlowState, and <Flow instance> lowers to the interactive PyreonFlowView (static custom node/edge maps, connection lines, handles, resizing, toolbars, background, controls, minimap, panels, colorMode including system, gestures, keyboard commands, culling, portable inline styles). The native engines replay shared scenarios against the web engine as oracle, and the iOS/Android device suites assert the rendered chrome and interactions. A <path d> in a custom edge or connection line lowers for any SVG path data, an inline <svg> in a renderer draws its shapes natively scaled by the viewBox, and plain <div>/<p>/<span> lower where the layout provably matches the browser. Browser-only by member and warned by name: FlowLayersContext, flowStyles, CSS-dependent renderers (a class/style, inline-flow mixes, CSS selectors), SVG <text>/gradients/transform, and renderer maps computed at runtime; @pyreon/flow/webview hosts the unchanged browser renderer for those.
See Multiplatform for the capability matrix and Multiplatform libraries for every package's tier.
Features
createFlow<TData> generic over node data shape
useFlow(config) component-scoped wrapper that auto-disposes on unmount
Custom node/edge renderers with reactive accessor props
Pan/zoom via pointer events + CSS transforms (no D3)
Auto-layout from the built-in engine — fed each node's EFFECTIVE (measured) box, not a 150×40 phantom
Measured node dimensions: per-node ResizeObserver records real rendered boxes + <Handle> dot centers; explicit width/height → measured → default precedence drives edges, layout, fitView, snap lines, minimap, culling
Handle-anchored edges: edge.sourceHandle/targetHandle attach at the MEASURED <Handle> dot center (unknown ids dev-warn + fall back to the first handle); handle-less nodes get floating natural-angle endpoints
Edge modes: bezier / smoothstep / step / straight / waypoints + per-edge pathOptions (curvature, borderRadius, offset) + config.defaultEdgeOptions flow-wide defaults
toJSON / fromJSON round-trip serialization
Configurable edge markers (arrow / arrowclosed, per-edge markerStart/markerEnd, deduped <defs>)
Complete --pyreon-flow-* CSS-var theming (25 variables, light-mode fallbacks — one CSS block re-skins nodes/edges/handles/controls/minimap)
Render virtualization via onlyRenderVisibleElements (cull off-screen nodes/edges, re-filter on pan/zoom)
Opt-out object-snapping (snapToObjects: false) — skips the O(N)/frame helper-line scan, ~3-4x faster drags on large graphs
Complete example
A full, end-to-end usage of the package:
import { createFlow, useFlow, Flow, Background, Controls, MiniMap, Handle, Position, type NodeComponentProps } from '@pyreon/flow'
// createFlow is generic over node `data` shape — typed consumers
// pass their data type explicitly and `node.data.kind` narrows
// correctly without any index signature on the 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' } },
{ id: '2', type: 'custom', position: { x: 200, y: 100 }, data: { kind: 'notify', label: 'End' } },
],
edges: [{ id: 'e1', source: '1', target: '2', animated: true }],
})
flow.addNode({ id: '3', type: 'custom', position: { x: 100, y: 200 }, data: { kind: 'transform', label: 'New' } })
flow.addEdge({ source: '1', target: '3' })
await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 })
// `direction`/`layerSpacing`/`edgeRouting` apply to layered/tree only —
// `force`/`stress`/`radial`/`box`/`rectpacking` silently ignore them.
// `nodeSpacing` is the only LayoutOptions field respected by every algorithm.
// Prefer `useFlow` inside components — it's `createFlow` + auto-dispose
// on unmount. Use `createFlow` only for singleton/app-store flows that
// outlive the component tree.
const MyDiagram = () => {
const flow = useFlow<WorkflowData>({ nodes: [], edges: [] })
return <Flow instance={flow}><Background /></Flow>
}
// Custom node renderers — every prop except `id` is a REACTIVE
// ACCESSOR (`() => T`), not a plain value. Read inside reactive
// scopes (JSX expression thunks, effect, computed) so the node
// patches in place when ANY underlying state changes. Each node
// mounts EXACTLY ONCE across the lifetime of the graph regardless
// of how many drags, selection clicks, or updateNode mutations
// happen — internally <Flow> uses <For> keyed by node.id plus
// per-node accessors that read live state from instance.nodes().
function CustomNode(props: NodeComponentProps<WorkflowData>) {
return (
<div
class={props.selected() ? 'selected' : ''}
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 variant="dots" gap={20} />
<Controls position="bottom-left" />
<MiniMap nodeColor={(node) => '#6366f1'} />
</Flow>
// Serialization round-trips for sidecar editors / persistence:
const json = flow.toJSON() // { nodes, edges, viewport }
flow.fromJSON({ nodes, edges }) // restore from saved stateExports
| Symbol | Kind | Summary |
|---|---|---|
createFlow | function | Create a reactive flow instance. |
useFlow | hook | Component-scoped wrapper around createFlow — identical shape plus an implicit onUnmount(() => flow.dispose()). |
Flow | component | Main flow container. |
Background | component | Grid background inside a <Flow>. |
Controls | component | Zoom / fit-view button cluster plus a live zoom-level readout. |
MiniMap | component | Overview minimap of the full graph. |
Handle | component | Connection handle on a custom node — exposes a connectable point that edges attach to. |
Panel | component | Overlay panel positioned absolutely relative to the flow viewport. |
NodeResizer | component | Render drag handles inside a custom node to resize it. |
NodeToolbar | component | A floating toolbar placed beside its host node (default position: "top", offset 8px). |
flowStyles | constant | The package stylesheet as ONE plain string — every .pyreon-flow-* rule with its --pyreon-flow-* custom-property fall |
EdgeLabelRenderer | component | HTML edge labels for CUSTOM edges. |
BaseEdge | component | The visible part of a CUSTOM edge: its stroke, markers and an optional label (React Flow <BaseEdge>). |
EdgeText | component | A text label at a point in flow coordinates, in the built-in edge-label style (11px, --pyreon-flow-edge-label). |
ViewportPortal | component | Renders arbitrary HTML in flow coordinates: the children pan and zoom with the graph (React Flow <ViewportPortal>). |
MarkerType / Position | constant | The two flow enums. |
edge-path-helpers | function | SVG-path builders for CUSTOM edge components. |
computeLayout | function | Auto-layout from the built-in engine. |
API
createFlow function
<TData = Record<string, unknown>>(config: FlowConfig<TData>) => FlowInstance<TData>Create a reactive flow instance. Generic over node data shape — createFlow<MyData>(...) returns FlowInstance<MyData> so node.data.kind narrows correctly without an [key: string]: unknown index signature on consumer types. Defaults to Record<string, unknown> when no generic is supplied. The returned instance owns signal-native nodes / edges and exposes CRUD, selection, viewport (zoom / pan / fitView), and auto-layout from the built-in engine (pure, synchronous, deterministic — no chunk to fetch). Pan / zoom uses pointer events + CSS transforms — no D3.
Example
// Generic over node data shape — typed consumers get strong narrowing
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' } },
{ id: '2', type: 'custom', position: { x: 200, y: 100 }, data: { kind: 'notify', label: 'End' } },
],
edges: [{ id: 'e1', source: '1', target: '2', animated: true }],
})
// node.data.kind narrows to the typed union, not unknown
const trigger = flow.findNodes((n) => n.data.kind === 'trigger')
flow.addNode({ id: '3', type: 'custom', position: { x: 100, y: 200 }, data: { kind: 'transform', label: 'New' } })
await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 })
// LayoutOptions applicability: direction / layerSpacing / edgeRouting apply to layered/tree only;
// force/stress/radial/box/rectpacking silently ignore them. nodeSpacing applies to all algorithms.
const json = flow.toJSON(); flow.fromJSON(json) // round-trip serializationCommon mistakes
Calling
pushHistory()before every mutation out of habit — mutations checkpoint themselves (autoHistory, default on) and a manual push right before one is deduped; setautoHistory: falseonly if you want the manual model back.Reading
flow.nodes()inside an event handler to get a snapshot — that is a TRACKED read; usegetNodes()/getEdges()/getViewport()for plain reads.Computing a drop position from
clientX - rect.leftby hand —screenToFlowPositionalready folds in the container rect, pan and zoom (andflowToScreenPositionis its inverse).Passing
isValidConnectionas a prop on<Flow>(React Flow) — here it is acreateFlowconfig key, alongsideconnectionRadius.Hunting for a
interactionWidthprop on<Flow>— hit width is per edge (interactionWidth) with a config default (edgeInteractionWidth, 20px); a click near a hairline edge already lands on it.Building a "figma-like" canvas by intercepting pointer events yourself —
selectionOnDrag: true+panOnDrag: [1, 2]is the whole recipe, anddeleteKeys/multiSelectionKey/selectionKeycover the key remaps.Giving a child node (
parentId) an ABSOLUTE position — a child position is relative to its parent; readgetAbsolutePosition(id)when you need the canvas coordinate.Wiring your own drag math to keep a child inside its group —
extent: 'parent'clamps it andexpandParent: truegrows the group instead.Forgetting to declare
@pyreon/runtime-domin consumer app deps — flow's JSX emits_tpl()which needs runtime-dom importsReading
NodeComponentProps.data/.selected/.draggingas plain values — all three are REACTIVE ACCESSORS:props.data(),props.selected(),props.dragging()Calling
props.data()OUTSIDE a reactive scope — captures the value once at component setup, defeating the per-node reactivity. Read it inside JSX expression thunks,effect, orcomputedAdding
[key: string]: unknownindex signature to your node data interface — no longer needed now thatcreateFlowis generic. PasscreateFlow<MyData>(...)insteadSetting
LayoutOptions.direction(orlayerSpacing, oredgeRouting) on a force / stress / radial / box / rectpacking layout and expecting a directional result — these options are namespaced under ELK's layered / tree pipelines and silently ignored by the geometric algorithms. Dev-modeconsole.warnfires when this happensMissing
<Flow nodeTypes={{ key: Component }}>registration —node.typestrings dispatch to that map, unregistered types fall through to the default rendererUsing
createFlowinside a component body withoutonUnmount(() => flow.dispose())— preferuseFlowwhich auto-disposesUsing
direction: 'row'on flow's containing Element layout — PyreonElementaccepts'inline'/'rows'/'reverseInline'/'reverseRows', not CSS flex-direction values like'row'or'column'Confusing
markerEnd: nullwith omitting it —nullis the explicit "no end arrow" opt-out that overridesconfig.defaultMarkerEnd; OMITTING it falls back to the flow default (a closed arrowhead). Setconfig.defaultMarkerEnd: nullto make every edge arrowless by defaultExpecting
onlyRenderVisibleElementsto cull an edge whose line crosses the viewport while BOTH its endpoint nodes are off-screen — only nodes (and the edges touching at least one visible node) are kept; a long edge spanning two off-screen nodes is culled (rare; matches React Flow)Assuming object-snapping rescans the graph per frame —
snapToObjects(defaulttrue) precomputes candidate guide lines ONCE at drag start (an O(N) pass) and each drag frame is a flat primitive-array scan; co-dragged nodes of a multi-drag are excluded as candidates (they move rigidly with the pointer). SetsnapToObjects: falseto skip object snapping entirely when you don't need helper-line alignmentSetting explicit
width/heighton every node "so layout works" — unnecessary: the renderer MEASURES each node's real box (and<Handle>dot centers) and feeds the effective size (explicit → measured → 150×40 default) to edges,layout(),fitView, snap lines, and the minimap. Explicit sizes are only needed for pre-render/headless layout (SSR, standalonecomputeLayout) — and they OVERRIDE the measurement, so a stale hardcoded size beats the real onePutting
pathOptionsfields on the wrong edge type —curvatureapplies tobezieronly,borderRadius+offsettosmoothstep,offsettostep;straightand waypoint routes ignore all of them. Flow-wide defaults go inconfig.defaultEdgeOptions(per-edge values, including an explicitmarkerEnd: null, always win)
See also: useFlow · FlowInstance · Flow
useFlow hook
<TData = Record<string, unknown>>(config: FlowConfig<TData>) => FlowInstance<TData>Component-scoped wrapper around createFlow — identical shape plus an implicit onUnmount(() => flow.dispose()). Prefer inside component bodies; use createFlow directly only for flows owned outside the component tree (app stores, singletons, SSR-shared state) where you'll dispose at the correct lifecycle point yourself.
Example
// Component-scoped flow — auto-disposes when the component unmounts.
// Identical shape to createFlow, plus an implicit onUnmount(() => flow.dispose()).
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>
)
}Common mistakes
Using
useFlowoutside a component body — theonUnmounthook registration requires an active component setup context, same constraint as everyuseXhookUsing
createFlowinside a component and forgettingonUnmount(() => flow.dispose())— that was the footgunuseFlowexists to preventStoring the returned instance in a module-level variable — bypasses the auto-dispose guarantee; use
createFlowfor that pattern
See also: createFlow
Flow component
(props: FlowComponentProps) => VNodeChildMain flow container. Accepts a FlowInstance via the instance prop plus optional nodeTypes / edgeTypes maps for custom renderers, style / class, and ariaLabel (the accessible name for the focusable canvas — defaults to "Flow diagram"; the container is role="group" + tabindex=0, so set a specific name like "Pipeline editor"). Internally uses <For> keyed by node.id plus per-node reactive accessors that read live state from instance.nodes() — each node mounts EXACTLY ONCE across the lifetime of the graph regardless of drags, selection clicks, or updateNode mutations. A 60fps drag in a 1000-node graph stays O(1) per frame. JSX components are NOT generic at the call site (<Flow<MyData> /> is invalid JSX); FlowProps.instance is typed as FlowInstance<any> so typed consumers can pass FlowInstance<MyData> without casting. ACCESSIBILITY (React Flow keyboard model): every node and edge is a focus stop after the canvas — Enter/Space select, the arrow keys move a node by 10 units (Shift: 100, one undo entry per press), Delete/Escape/undo shortcuts bubble to the canvas; nodes are role="group" + aria-roledescription="node" (optional ariaLabel), edges are named buttons, both described by visually-hidden instructions, and a polite live region announces selection changes and keyboard moves. flowStyles themes :focus-visible and honours prefers-reduced-motion. Opt out with focusable: false per element, nodesFocusable / edgesFocusable per default, or disableKeyboardA11y: true.
Example
<Flow instance={flow} ariaLabel="Pipeline editor" nodeTypes={{ custom: MyNode }} edgeTypes={{ arrow: ArrowEdge }}>
<Background variant="dots" gap={20} />
<Controls position="bottom-left" />
<MiniMap nodeColor={(node) => '#6366f1'} />
</Flow>
// Custom node renderer — every prop except id is a REACTIVE ACCESSOR
function MyNode(props: NodeComponentProps<WorkflowData>) {
return (
<div
class={props.selected() ? 'selected' : ''}
style={() => `cursor: ${props.dragging() ? 'grabbing' : 'grab'}`}
>
{props.data().label}
</div>
)
}Common mistakes
Setting an inline
outline: noneon the canvas or nodes to hide the click ring — it hides KEYBOARD focus too; the UA andflowStylesalready scope the ring to:focus-visible, so a pointer click draws none.Skipping
flowStylesand wondering why the focus ring is the browser default and animated edges still dash under reduced motion — the themed ring and theprefers-reduced-motionrules live in that stylesheet.Asserting keyboard a11y with
hasAttribute— a node withfocusable: falsestill HAStabindex(-1); assert the value.<Flow<MyData> />is invalid JSX — the component is not generic at the call site; pass a typedFlowInstance<MyData>viainstancepropMissing
nodeTypesentry for anode.typestring — falls through to the default rendererMutating
instance.nodes()return value directly — useinstance.addNode/updateNode/removeNodeso the internal signals fire
See also: createFlow · Background · Controls · MiniMap · Handle
Background component
(props?: { variant?: "dots" | "lines" | "cross"; gap?: number; size?: number; color?: string }) => VNodeChildGrid background inside a <Flow>. Place as a direct child. variant is "dots" (default), "lines", or "cross"; gap is the pattern spacing (default 20); size is the dot radius / line thickness (default 1); color sets the pattern color (default "#ddd"). Renders as an SVG <pattern> at the back of the z-order.
Example
<Flow instance={flow}>
<Background variant="dots" gap={24} size={1} color="#e5e7eb" />
</Flow>See also: Flow · Controls · MiniMap
Controls component
(props?: { showZoomIn?: boolean; showZoomOut?: boolean; showFitView?: boolean; showLock?: boolean; position?: "top-left" | "top-right" | "bottom-left" | "bottom-right" }) => VNodeChildZoom / fit-view button cluster plus a live zoom-level readout. Renders absolutely inside the flow viewport at the configured corner (default "bottom-left"). showZoomIn / showZoomOut / showFitView default to true and dispatch to instance.zoomIn() / zoomOut() / fitView(); showLock (default false) adds a lock/unlock toggle. Each button is title-labelled for accessibility.
Example
<Flow instance={flow}>
<Controls position="bottom-right" showLock />
</Flow>See also: Flow · Background · MiniMap
MiniMap component
(props?: { nodeColor?: string | ((node: FlowNode) => string); maskColor?: string; width?: number; height?: number; style?: string; class?: string }) => VNodeChildOverview minimap of the full graph. nodeColor is a flat color string OR a per-node color function (default a --pyreon-flow-minimap-node CSS var); maskColor fills the area outside the current viewport (default a --pyreon-flow-minimap-mask var). width / height size the minimap box (default 200 × 150). Clicks on the minimap recenter the main viewport.
Example
<Flow instance={flow}>
<MiniMap nodeColor={(node) => node.data.highlighted ? '#f59e0b' : '#6366f1'} />
</Flow>See also: Flow · Background · Controls
Handle component
(props: { type: "source" | "target"; position: Position; id?: string; offset?: number; style?: string; class?: string }) => VNodeChildConnection handle on a custom node — exposes a connectable point that edges attach to. type picks direction ("source" emits edges, "target" receives), position is a Position enum (Top / Right / Bottom / Left). Provide a distinct id when a node has multiple source or target handles so edges can reference the specific one via edge.sourceHandle / edge.targetHandle — the edge then anchors at the dot's MEASURED rendered center (the NodeLayer records every dot via its per-node ResizeObserver), so restyling/repositioning a dot via style / class moves the edge attachment with it. An edge with no handle id uses the node's first handle of the right type; an unknown id anchors at the first handle and dev-warns once naming the known ids.
Example
function CustomNode(props: NodeComponentProps<MyData>) {
return (
<div>
<Handle type="target" position={Position.Left} />
{props.data().label}
{/* Same side, distinct offsets (percent along the side) so the dots don't stack */}
<Handle type="source" position={Position.Right} id="out-primary" offset={30} />
<Handle type="source" position={Position.Right} id="out-fallback" offset={70} />
</div>
)
}
// Edge referencing a specific source handle by id — anchors at that dot's
// MEASURED rendered center, so the offset moves the attachment point too
flow.addEdge({ source: '1', sourceHandle: 'out-primary', target: '2' })Common mistakes
Multiple
source/targethandles on one node without distinctidvalues — edges cannot disambiguate which handle they connect toTwo handles on the SAME side without distinct
offsetvalues — both render at that side's center and visually stack; give same-side siblings distinct offsets (percent along the side, default 50) and the measured-dot anchoring moves each edge attachment with its dotNesting a
<Handle>inside a non-node component (a<Background>child, a<Panel>, etc.) — the connection machinery expects handles to live inside a node renderer
See also: Flow · Position
Panel component
(props: { position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; style?: string; class?: string; children?: VNodeChild }) => VNodeChildOverlay panel positioned absolutely relative to the flow viewport. Use for toolbars, legend badges, or contextual action buttons. Pass any JSX as children — the panel is a plain positioned container, not a predefined chrome component. style / class customise the container.
Example
<Flow instance={flow}>
<Panel position="top-right">
<button onClick={() => flow.fitView()}>Fit</button>
<button onClick={() => flow.toJSON()}>Export</button>
</Panel>
</Flow>See also: Flow · Controls
NodeResizer component
NodeResizer(props: { nodeId: string; instance: FlowInstance; minWidth?: number; minHeight?: number; handleSize?: number; showEdgeHandles?: boolean }) => VNodeChildRender drag handles inside a custom node to resize it. Draws absolutely-positioned corner handles (nw/ne/sw/se) — plus edge handles when showEdgeHandles — that drag via pointer-capture (no document listeners), convert the client delta by the current viewport.zoom, and call instance.updateNode(nodeId, { width, height, position }). w/n-side drags also shift position so the opposite edge stays fixed. Defaults: minWidth 50, minHeight 30, handleSize 8px.
Example
import { NodeResizer } from '@pyreon/flow'
const ResizableNode = (props) => (
<div style={{ position: 'relative' }}> {/* required — see mistakes */}
<NodeResizer nodeId={props.id} instance={flow} minWidth={80} />
{props.data.label}
</div>
)Common mistakes
Expecting it to read the flow from context like React Flow — it does NOT; you MUST pass
instance={flow}(yourcreateFlow()handle) ANDnodeIdexplicitly.Mounting it in a node whose host element is not
position: relative— the handles areposition: absoluteand will anchor to the wrong ancestor. Wrap the node content in aposition: relativeelement.
See also: NodeToolbar · Handle · useFlow
NodeToolbar component
NodeToolbar(props: { nodeId?: string; position?: 'top' | 'bottom' | 'left' | 'right'; align?: 'start' | 'center' | 'end'; offset?: number; showOnSelect?: boolean; selected?: boolean | (() => boolean); style?: string; class?: string; children?: VNodeChild }) => VNodeChildA floating toolbar placed beside its host node (default position: "top", offset 8px). Returns a REACTIVE thunk that reads selected and renders null when showOnSelect (default true) and the node is not selected — so it shows/hides with live selection. Put action buttons for a node (delete, duplicate, edit) here.
Example
import { NodeToolbar } from '@pyreon/flow'
const NodeWithToolbar = (props) => (
<div style={{ position: 'relative' }}>
<NodeToolbar selected={props.selected}> {/* pass the accessor */}
<button onClick={() => flow.removeNode(props.id)}>Delete</button>
</NodeToolbar>
{props.data.label}
</div>
)Common mistakes
Expecting it to escape node clipping like React Flow — it is NOT a portal; it renders inline as an absolutely-positioned div, so an ancestor
overflow: hiddenCLIPS it. The host node must beposition: relative.Passing a bare boolean
selected={someValue}— that snapshots selection and never updates. Pass the reactive accessor (the custom node'sprops.selected, which is() => boolean) so show/hide tracks live selection.
See also: NodeResizer · Handle
flowStyles constant
flowStyles: stringThe package stylesheet as ONE plain string — every .pyreon-flow-* rule with its --pyreon-flow-* custom-property fallbacks (26 theme vars, all documented under "Theming"). Inject it once per document (<style>{flowStyles}</style>, a useHead style, or your CSS pipeline); it is an inert string with no side effects, so sideEffects: false tree-shakes it out of apps that ship their own CSS. Theme by overriding the variables on an ancestor, never by editing the string.
Example
import { flowStyles } from '@pyreon/flow'
// once, near the root
<style>{flowStyles}</style>
<div style="--pyreon-flow-edge: #7c3aed; --pyreon-flow-node-bg: #111">
<Flow instance={flow} />
</div>Common mistakes
Forgetting to inject it — nodes render unstyled and edges invisible (the SVG has no stroke), which reads as a geometry bug.
Injecting it per <Flow> instance — it is document-scoped; N copies are N identical stylesheets.
See also: Flow · Background
EdgeLabelRenderer component
EdgeLabelRenderer(props: { children?: VNodeChild }) => VNodeChildHTML edge labels for CUSTOM edges. SVG <text> cannot hold buttons, inputs or wrapped rich text, so a custom edge wraps its label in this component: the children are portaled into a <div> layer INSIDE the viewport (they pan and zoom with the graph) and position themselves with the EdgeComponentProps.labelX / labelY accessors (the built-in label anchor for the same edge). The layer is pointer-events: none; an interactive label opts back in with pointer-events: all plus the nopan class so a click on it does not start a canvas pan. Renders nothing outside a mounted <Flow> and on the server. React Flow <EdgeLabelRenderer>.
Example
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>
</>
)
}Common mistakes
Rendering the label as a plain
<div>inside the edge — it lands inside the<svg>, which browsers do not render as HTML.Positioning with
left/topin pixels — the layer is INSIDE the zoomed viewport, so use atransform: translate(labelX, labelY)in flow units; the viewport transform does the rest.Forgetting
pointer-events: all+nopanon an interactive label — the layer ignores the pointer, and a click that reaches the canvas starts a pan.
See also: NodeToolbar · Flow
BaseEdge component
BaseEdge(props: BaseEdgeProps) => VNodeChildThe visible part of a CUSTOM edge: its stroke, markers and an optional label (React Flow <BaseEdge>). path is SVG path data, typically from getBezierPath / getSmoothStepPath; the default stroke is --pyreon-flow-edge at 1.5px, and a style string REPLACES that default wholesale, as on any SVG element. label + labelX + labelY add an <EdgeText>. The flow already wraps every edge in a wider invisible hit path, so BaseEdge draws only what you see. Lowers natively (a native stroke plus label; markerStart / markerEnd are url(#…) references with no native meaning and are reported).
Example
function Wire(props: EdgeComponentProps) {
const curve = () => getBezierPath({ sourceX: props.sourceX(), sourceY: props.sourceY(), targetX: props.targetX(), targetY: props.targetY() })
return <BaseEdge path={curve().path} label="wire" labelX={curve().labelX} labelY={curve().labelY} />
}Common mistakes
Passing a
stylethat sets onlyopacityand expecting the default stroke to remain —stylereplaces the default declaration, so nostrokemeans no line.Setting
labelwithoutlabelX/labelY— the label has no position and is not drawn.Reading
props.sourceXwithout calling it — the edge props are accessors (props.sourceX()).
See also: EdgeText · EdgeLabelRenderer · edge-path-helpers
EdgeText component
EdgeText(props: { x: number; y: number; label: string; style?: string }) => VNodeChildA text label at a point in flow coordinates, in the built-in edge-label style (11px, --pyreon-flow-edge-label). React Flow <EdgeText>. For interactive or rich labels use <EdgeLabelRenderer>, which renders HTML. Lowers natively.
Example
<EdgeText x={edge().labelX} y={edge().labelY} label="42 ms" />Common mistakes
Putting buttons or wrapped text in it — it is SVG
<text>; use<EdgeLabelRenderer>for HTML.
See also: BaseEdge · EdgeLabelRenderer
ViewportPortal component
ViewportPortal(props: { children?: VNodeChild }) => VNodeChildRenders arbitrary HTML in flow coordinates: the children pan and zoom with the graph (React Flow <ViewportPortal>). Position them with a transform: translate(x, y) in flow units. Renders nothing outside a mounted <Flow> and on the server. WEB-ONLY: CSS positioning has no native meaning, so the compiler reports it and drops it; use <EdgeLabelRenderer>, <NodeToolbar> or <Panel> on native.
Example
<Flow instance={flow}>
<ViewportPortal>
<div style="position: absolute; transform: translate(200px, 80px)">Annotation</div>
</ViewportPortal>
</Flow>Common mistakes
Positioning with
left/topin screen pixels — the layer is inside the zoomed viewport, so use flow units.
See also: EdgeLabelRenderer · Panel
MarkerType / Position constant
enum MarkerType { Arrow = 'arrow', ArrowClosed = 'arrowclosed' } · enum Position { Top = 'top', Right = 'right', Bottom = 'bottom', Left = 'left' }The two flow enums. MarkerType is the edge-arrowhead shape — Arrow (open stroked chevron) or ArrowClosed (filled triangle, the default edge-end marker). Position is a node's handle/edge attachment side — Top/Right/Bottom/Left — consumed by the edge-path helpers and getHandlePosition. Both match React Flow's enums exactly.
Example
import { MarkerType, Position } from '@pyreon/flow'
const edges = [{ id: 'e1', source: 'a', target: 'b',
markerEnd: { type: MarkerType.ArrowClosed } }]See also: edge-path-helpers · Handle
edge-path-helpers function
getBezierPath / getSmoothStepPath / getStraightPath / getStepPath / getWaypointPath / getEdgePath => { path: string; labelX: number; labelY: number } · getEffectiveDimensions / getFloatingEndpoints / getNodeIntersection / getSmartHandlePositions / resolveHandleAnchor (anchoring) · collectEdgeMarkers / resolveEdgeMarkers / resolveMarker / markerId / DEFAULT_MARKER_END (markers) · DEFAULT_NODE_WIDTH / DEFAULT_NODE_HEIGHT · getHandlePosition / getSmartHandlePositionsSVG-path builders for CUSTOM edge components. getBezierPath, getSmoothStepPath, getStraightPath, getStepPath, getWaypointPath take a single OPTIONS object ({ sourceX, sourceY, sourcePosition?, targetX, targetY, targetPosition?, … }) and return an EdgePathResult object { path, labelX, labelY }. getEdgePath(type, sourceX, sourceY, sourcePos, targetX, targetY, targetPos) is the POSITIONAL-arg dispatcher (unknown type → bezier). getHandlePosition(position, nodeX, nodeY, nodeW, nodeH) returns the { x, y } anchor on a node edge; getSmartHandlePositions(sourceNode, targetNode) auto-picks the closest facing sides.
Example
import { getBezierPath } from '@pyreon/flow'
const MyEdge = (props) => {
const { path, labelX, labelY } = getBezierPath({
sourceX: props.sourceX, sourceY: props.sourceY,
targetX: props.targetX, targetY: props.targetY,
})
return <path d={path} />
}Common mistakes
Destructuring the return as a TUPLE (
const [path, labelX, labelY] = getBezierPath(...)) — React Flow returns an array, but @pyreon/flow returns an OBJECT{ path, labelX, labelY }. Destructure by NAME.Calling
getEdgePath/getHandlePositionwith an options object — those two take POSITIONAL args (unlike the five object-param helpers).getStraightPathalso takes no*Positionparams.
See also: MarkerType / Position · Handle
computeLayout function
computeLayout<TData>(nodes: FlowNode<TData>[], edges: FlowEdge[], algorithm?: 'layered' | 'force' | 'stress' | 'tree' | 'radial' | 'box' | 'rectpacking', options?: { direction?: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; nodeSpacing?: number; layerSpacing?: number; edgeRouting?: 'orthogonal' | 'splines' | 'polyline' }) => Promise<Array<{ id: string; position: { x: number; y: number } }>>Auto-layout from the built-in engine. Runs algorithm (default layered) over the graph and returns a NEW array of { id, position } pairs (positions only). The engine is PURE and synchronous — the same graph always produces the same positions — but the function stays async because that is the published signature, and it leaves room to move heavy layouts to a worker later without another breaking change.
Example
import { computeLayout } from '@pyreon/flow'
const positioned = await computeLayout(flow.nodes(), flow.edges(), 'layered', { direction: 'DOWN' })
for (const { id, position } of positioned) flow.updateNode(id, { position })Common mistakes
Forgetting to
awaitit —computeLayoutreturns a Promise even though the engine underneath is synchronous.Expecting it to move your nodes — it does NOT mutate
nodes; it returns only{ id, position }pairs (no width/height/data). Map the positions back onto your node objects (e.g. viaupdateNode).Passing
direction/layerSpacing/edgeRoutingto a non-layeredalgorithm — those apply only tolayered(anddirectiontotree); ELK silently ignores them (a dev-mode warning fires).
See also: createFlow · useFlow
Package-level notes
Note: LayoutOptions.direction / layerSpacing / edgeRouting apply to layered/tree only — force/stress/radial/box/rectpacking silently ignore them. nodeSpacing is the only field respected by every algorithm. Dev-mode console.warn fires when an option is set on an algorithm that ignores it.
Mount once: Each node mounts exactly once across the lifetime of the graph — drags, selection, and updateNode mutations patch via per-node reactive accessors, not remount.
Peer dep rationale:
@pyreon/runtime-domis required in consumer apps because flow JSX components emit_tpl()/_bind()calls — declare it as a direct dependency, not a transitive one.
JSX generics: Pyreon JSX components cannot be parameterised at the call site (
<Flow<MyData> />is not valid JSX).FlowProps.instanceis typed asFlowInstance<any>so typed consumers can pass theirFlowInstance<MyData>without casting.
Effective dimensions: Every geometry consumer (edges, layout(), fitView, snap lines, minimap, culling) resolves node size as explicit width/height → MEASURED rendered box → 150×40 default. Nodes need no explicit sizes in the browser; explicit sizes override the measurement, and headless
computeLayout(no DOM) sees explicit-or-default only.
Theming: All renderer colors go through
--pyreon-flow-*CSS custom properties (25 vars) with light-mode fallbacks — set them on the container to re-skin; SVG colors are applied viastyle(never presentation attributes, wherevar()is invalid).