component
An infinite, pannable graph surface — nodes you can drag, ports you can connect, and edges that route themselves.
Flow is deliberately the only graph-aware family in pindoba, and it is quarantined in a single package family so it can be dropped without touching anything else. It composes pan-zoom for the viewport transform and adds the graph semantics on top: node geometry, selection, marquee, and connection dragging.
It is controlled. Node positions come in as nodes and every drag is reported through onNodesChange; the flow renders whatever you pass back. Edges are derived — you describe them as { source, target } and their geometry is computed from the current node positions in core, so all four frameworks route identically.
Drag a node to move it. Drag empty space to rubber-band select. Two-finger scroll pans the surface, Ctrl/⌘-scroll zooms, and holding Space (or the middle mouse button) turns any drag into a pan.
Only the dragged node and the rest of its selection move; edges re-route to follow, because their endpoints are recomputed from node geometry rather than stored alongside it. Drag from a port onto another node to connect them — a dashed preview follows the pointer and the hovered node signals whether the drop will be accepted — and press Delete to remove whatever is selected.
---
import { Flow, FlowNode } from "../../index";
import { css } from "@pindoba/styled-system/css";
import type { FlowPortDescriptor } from "@pindoba/core-flow";
// Inputs on the top/left, outputs on the bottom/right. An edge that names no
// port floats between them — `review` sits BELOW `trigger`, so that edge
// routes bottom→top while `review`→`gate` stays horizontal.
const PORTS: FlowPortDescriptor[] = [
{ side: "top", kind: "input" },
{ side: "start", kind: "input" },
{ side: "end", kind: "output" },
{ side: "bottom", kind: "output" },
];
const nodes = [
{
id: "trigger",
x: 60,
y: 60,
width: 150,
height: 62,
ports: PORTS,
label: "Trigger",
},
{
id: "review",
x: 90,
y: 300,
width: 150,
height: 62,
ports: PORTS,
label: "Security Review",
},
{
id: "gate",
x: 420,
y: 330,
width: 150,
height: 62,
ports: PORTS,
label: "Human Review",
},
];
// Edge data only — `<Flow>` resolves the geometry server-side from the same
// node positions, through the same core resolver the reactive frameworks use.
const edges = [
{ id: "e1", source: "trigger", target: "review", marker: "arrow" as const },
{ id: "e2", source: "review", target: "gate", marker: "arrow" as const },
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow {nodes} {edges}>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
ports={node.ports}
>
<div class={card}>{node.label}</div>
</FlowNode>
))
}
</Flow>
</div><FlowNode> positions itself with a translate() rather than top/left: a transform is compositor-only, so dragging a large selection doesn’t trigger a layout pass per node per frame. Its body is arbitrary content — normally a Card or Panel.
A draggable node carries data-no-pan, which is what stops the surface panning out from under it. That marker is a pan-zoom feature, so the same escape hatch works for any interactive content you place on a transformed surface.
A resizable node grows corner handles while selected; dragging one reports the new geometry through onNodesChange, exactly like a move — grid-snapped against the fixed corner and clamped to a minimum size, with Escape restoring what you started from. A node with a parent belongs to that node’s group: dragging or nudging the parent carries the whole subtree along (coordinates stay absolute). A node that other nodes name as their parent becomes a container and stays beneath the edge layer in every state, dragging and selection included — painted above, its card would swallow every connection its own members make, and the graph would read as lines that simply stop at its border. Edge labels still sit above the lines they annotate. A toolbar slot renders an action strip above the node whenever it is selected, counter-scaled against the zoom so it stays readable at any magnification.
With alignmentGuides on, a dragged node snaps onto its neighbours’ edges and centres when it comes within a few pixels, and accent hairlines show exactly what it locked onto.
Interactive content inside a node body — an input, a button, a link — owns its own presses: the flow never hijacks them into a drag, so a form inside a node just works. Anything else can opt out of dragging with data-flow-nodrag.
<FlowPort> is a real <button>, so a connection can be started from the keyboard rather than only by dragging a 9px target: Enter on a focused port anchors the connection, Tab previews it against each port it lands on, and Enter on another node’s port completes it (Escape abandons). Its visible glyph is small but its hit area is much larger — a dot you cannot grab is not an affordance. Ports default to square; dot is opt-in, and a port label renders INSIDE the node, on the port’s interior side — outside it would lie on the canvas, right where the port’s own edges arrive.
Ports can be written as children or declared as data on the node — ports: [{ side: "top", kind: "input" }, …]. Data ports are the better default: the flow renders them for you, and because core knows where a node’s connection points are, edges can route through the ones that actually exist rather than at a bare side. Children remain the escape hatch for ports that need custom content.
Free-placed ports position themselves anywhere along a side, which is right for a handful of anonymous connection points and wrong the moment they are named: a free port’s label is absolutely positioned text pulled back into the node, reserving no space, so on a node with real body content the two collide.
sockets declares connection points as a band of rows below the body instead. Each row spans the node’s full width — so its ports still straddle the borders — while its label sits beside the glyph in ordinary layout. Nothing overlays anything, and a label too long for its lane truncates rather than pushing the row wider than the node:
sockets: [
{ id: "yes", start: { label: "plan" }, end: { label: "approved" } },
{ id: "no", end: { label: "rejected" } },
];
A row’s ports need no side and no position: which end of the row it is decides the side, and which row it is decides the height. kind defaults by end — start takes input, end gives output — and shape, maxConnections and an explicit id ride along as usual.
Geometry comes out of the row index alone, with nothing measured: rows are the node’s last band and each is socketRowHeight (26px) tall, so a row’s ports sit a known number of px above the node’s bottom edge. That is what lets a statically rendered Astro band route identically to a reactive one, and why retuning the height belongs on the node (socketRowHeight republishes --flow-socket-row) rather than in CSS — the band and the anchor arithmetic share the one number. Give a socketed node an explicit height, so the band’s rows and the anchors agree.
Routing reads sockets exactly as it reads ports, so an edge that names no port floats onto whichever row faces its target — in the demo below, one output row reaches the node above and the other the node below, with nothing pinned. ports and sockets can also coexist on one node: the free ports keep the perimeter, the band owns the rows.
Each row’s middle lane is yours: a socketRow snippet (Svelte), render prop (React), scoped slot (Vue) or per-row socket-<row id> slot (Astro) fills the space between the two labels with anything — a select, a value, a stepper.
---
import { Flow, FlowNode } from "../../index";
import { css } from "@pindoba/styled-system/css";
import type { FlowSocketDescriptor } from "@pindoba/core-flow";
// A socket BAND instead of free-placed ports: each connection gets its own
// row, so its label sits beside the glyph in real layout and can never land
// on the body's text. Edges name no port, so they float onto whichever row
// faces their target — the band is one source of truth for both, resolved
// server-side here through the very same core geometry.
const inbox: FlowSocketDescriptor[] = [{ start: { label: "in" } }];
const nodes = [
{
id: "review",
x: 40,
y: 150,
width: 190,
height: 104,
label: "Approve plan",
sockets: [
{ id: "yes", start: { label: "plan" }, end: { label: "approved" } },
{ id: "no", end: { label: "rejected" } },
] satisfies FlowSocketDescriptor[],
},
{
id: "ship",
x: 340,
y: 60,
width: 150,
height: 78,
label: "Ship it",
sockets: inbox,
},
{
id: "revise",
x: 340,
y: 270,
width: 150,
height: 78,
label: "Send back",
sockets: inbox,
},
];
const edges = [
{ id: "e1", source: "review", target: "ship", marker: "arrow" as const },
{ id: "e2", source: "review", target: "revise", marker: "arrow" as const },
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
// The FRAME goes on the node root, not on the body: the socket band is a
// sibling of the body, so a card drawn around the body alone leaves the rows
// hanging off an object they belong to.
const nodeFrame = {
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
// No `overflow: hidden` — a port glyph straddles the node border by half
// its width, and clipping the frame would shave every one of them in half.
borderRadius: "xs",
} as const;
const card = css({
height: "100%",
paddingInline: "xs",
paddingBlock: "2xs",
fontSize: "xs",
fontWeight: "medium",
});
const lane = css({
fontSize: "xs",
fontFamily: "mono",
color: "neutral.text.muted",
textAlign: "center",
});
---
<div class={frame}>
<Flow {nodes} {edges}>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
sockets={node.sockets}
passThrough={{ root: { style: nodeFrame } }}
>
<div class={card}>{node.label}</div>
{/* A row's middle lane is addressed by a `socket-<row id>` slot —
here the threshold the "approved" branch tests. */}
{node.id === "review" && (
<span slot="socket-yes" class={lane}>
≥ 0.8
</span>
)}
</FlowNode>
))
}
</Flow>
</div>Dragging out of a port draws a dashed connection preview routed the same way the finished edge will be. Over a droppable node it firms up solid and the node shows an accent outline; over one the rules reject, both flip to the danger palette. The drop snaps onto any port within reach — the ghost locks onto the port anchor rather than trailing the pointer — and dropping calls onConnect(from, to, detail), where detail names the exact source and target ports (side + offset), so a multi-input node knows which input was wired. Append the edge to your data and it appears.
Which connections are possible is decided in core, so all four frameworks agree and the preview can never promise a drop the release then refuses. Pass the current edges and the rules apply themselves:
duplicates — reject-undirected (the default) refuses an edge between two ports that are already connected in either direction, so A→B followed by B→A can’t stack two curves along the same line. reject-directed allows the reverse as a distinct edge; allow turns the check off. An edge that names no ports occupies every port on its nodes, so the check can’t be defeated by omitting them.connectionMode — strict wires an output into an input and nothing else, using the kind each port already declares. loose (the default) lets any port reach any other.maxConnections — a cap per port, set on the port itself ({ side: "start", kind: "input", maxConnections: 1 }) or flow-wide as a fallback. A node can also cap its total across every port.isValidConnection(from, to, candidate) — your own rule, consulted last, so it can only ever narrow what the declarative rules permit. candidate describes both ends — side, offset and kind — so a rule can be port-aware rather than only node-aware.Because the flow is controlled, none of this mutates your data: a refused connection simply never fires onConnect.
Edges are also reconnectable: grab an existing edge near either tip and it detaches into a connection gesture anchored at the other end; dropping fires onReconnect(edgeId, from, to, detail) and you update source/target in your data. While the gesture is near a viewport edge, the surface auto-pans under it — the same applies to node drags and marquees. A gesture that merely starts in that border zone doesn’t pan until it leaves it, so grabbing a node near the frame (or one a zoom-in pushed there) doesn’t slide the surface out from under a pointer that never asked to travel.
---
import { Flow, FlowNode, FlowPort } from "../../index";
import { css } from "@pindoba/styled-system/css";
// Port anatomy: inputs are round, outputs square; two outputs share a side
// via `offset`; labels sit clear of their glyphs. (`isValidConnection` is a
// callback, so connection RULES need a client framework — in plain Astro
// every drop connects and `flow:connect` is surfaced as a CustomEvent.)
const nodes = [
{ id: "source", x: 40, y: 120, width: 130, height: 72 },
{ id: "sink", x: 420, y: 120, width: 130, height: 72 },
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow {nodes}>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
>
<div class={card}>{node.id}</div>
<Fragment slot="ports">
{node.id === "source" ? (
<Fragment>
<FlowPort side="end" kind="output" offset={0.25} label="ok" />
<FlowPort side="end" kind="output" offset={0.75} label="err" />
</Fragment>
) : (
<FlowPort side="start" kind="input" shape="dot" label="in" />
)}
</Fragment>
</FlowNode>
))
}
</Flow>
</div>Edges render as two overlapping paths: the visible hairline and a fat transparent twin beneath it that takes the clicks. A 1.5px stroke is effectively un-hittable, and thickening the visible line to compensate would make the graph look like plumbing. Clicking an edge selects it — it thickens and takes the accent color (Shift-click toggles) — and disconnecting is selecting an edge and pressing Delete: onDelete reports its id and you drop it from your data. Per-edge selected, animated and feedback can also ride directly on the edge data.
In Astro, pass nodes and edges as data and <Flow> resolves the geometry server-side through the same core resolver — the boot controller re-routes each edge as its nodes move, exactly like the reactive frameworks recompute theirs. (Hand-authored <FlowEdge slot="edges"> children still work; give them source/target or their path stays static.)
A standalone Astro flow is self-sufficient: the DOM is the graph state, so a dropped connection materializes as a real edge, a grabbed endpoint rewires it, and Delete removes the selected nodes and edges (plus anything they orphaned). The mutating flow:connect / flow:reconnect / flow:delete CustomEvents are cancelable — call preventDefault() to own the graph yourself and the boot touches nothing.
A directed edge takes marker (and/or markerStart) — arrow, circle or diamond. Each tip is a filled sibling path positioned from the rendered geometry — no per-instance SVG <defs> — so it recolors with selection and follows re-routes for free.
Line appearance rides on the edge data: feedback recolors an edge with a semantic palette, lineStyle picks solid (default), dashed or dotted, weight picks thin/regular/bold (selection thickens relative to it), and animated marches the dashes (it implies the dashed pattern — the animation is the dashes moving). A label string renders at the edge’s midpoint in every framework including Astro, re-centred as the edge re-routes; rich label content uses the edgeLabel snippet/render prop instead. Exact one-off styling goes through passThrough.path.style, like any other slot.
Label placement is collision-aware on every routing kind, not only smart. The anchor is chosen from the path the edge actually draws — a bezier is sampled along its own curve, a step edge scored at its corners — and what gets fitted is the label’s box, estimated from the text, because it is the width that decides whether a pill clears the nodes an edge runs between. A point-sized check happily parks a label in a 30px gap its pill overlaps on both sides. When no stretch of the route is clear at all — a short edge between two neighbours has none to offer — the label steps perpendicular off the line rather than printing over a node’s content, bounded so it never floats far enough to stop reading as that edge’s label.
For rich label content (the edgeLabel snippet / render prop) there is no text to estimate from, so declare labelSize: { width, height } on the edge data or the label is placed as a dimensionless point.
A label sits on the line it annotates, so it ships with a chip of its own — text laid straight over its own edge reads as struck through. edgeLabelAppearance picks the visual: pill (the default chip) or plain, which keeps only the positioning for labels you paint yourself through the edgeLabel snippet. Retuning the chip in place is a var redefinition, not a restyle — --flow-label-pad-x, --flow-label-pad-y, --flow-label-radius, --flow-label-bg, --flow-label-border and --flow-label-color, set once through passThrough.edgeLabel.style:
passThrough={{
edgeLabel: {
style: {
"--flow-label-bg": "primary.surface.peak",
"--flow-label-border": "primary.border.accent",
"--flow-label-radius": "radii.full",
},
},
}}
Routing is bezier by default, with smoothstep (orthogonal, rounded corners), straight and smart also available — flow-wide via edgePath, or per edge by putting edgePath on the edge data (a statically rendered Astro edge is re-routed in its own kind too). smart is smoothstep with collision avoidance: the route detours around every node in its way (keeping a small clearance) instead of cutting through, re-planning as nodes move — in the demo below, the direct ingest → publish edge steps around the Transform node. Smart edges also avoid each other: parallel runs sharing a corridor are spread a few px apart instead of drawing line over line (tune with spacing, 0 disables). Their labels ride the openest stretch of the route — the anchor maximizes clearance from nodes (weighted for a pill’s wide shape) instead of taking the straight-line midpoint, which would hang it over the very node being avoided — and labels that would still collide slide apart along their own runs. A labelled smart edge also routes with a wider node clearance (labelMargin, default 28) so the pill’s height never overlaps the node the route skirts. When no clear route exists at all an edge degrades to the plain smoothstep shape.
For the “I have a graph but no positions” moment there is a layered auto layout: nodes rank along their longest path from a source, each layer is ordered to untangle crossings, and layers centre on the cross axis. Three ways in, all the same engine:
autoLayout on the flow arranges the graph once on mount (replacing the positions the data carried, reported through onNodesChange). Pass true, or an options object — { direction: "right" | "down", gap, layerGap, origin }.<FlowControls> now includes an auto-layout button, so the user can re-arrange a graph they’ve dragged into knots at any time.layoutNodes(nodes, edges, options) (from @pindoba/core-flow) is the pure function underneath — call it yourself to compute initial positions, and api.layout() on the imperative surface triggers the same arrangement plus a re-frame.import { layoutNodes } from "@pindoba/core-flow";
const placed = layoutNodes(nodes, edges, { direction: "right", layerGap: 80 });
An edge that doesn’t name its ports floats: every time the nodes move, each end re-picks the declared port that best faces the other node, preferring an output at the source and an input at the target. Drag a node below its neighbour and the edge leaves through the bottom port and arrives at the top — it is not frozen to whichever side it was first drawn from. Naming sourcePort/targetPort on the edge data pins that end instead, which is what you want when the specific port carries meaning (an ok versus an err output). A node that declares no port of the needed kind falls back to anchoring on the bare side, exactly as before. sourceOffset/targetOffset are honoured either way: the side keeps floating, but where the tip sits along it stays where you pinned it — so an edge can arrive a third of the way down a node’s side without being frozen to that side.
snapToGrid snaps the grabbed node’s resulting position and moves the rest of the selection by the same correction, so the node lands exactly on a gridline while the selection keeps its internal spacing. It also quantizes node sizes onto the same lattice: a snapped position only puts a node’s top-left corner on a gridline, so without this its right and bottom edges would still stop wherever its content happened to fall — which is what makes an otherwise-snapped graph read as off-grid. A node never shrinks below one cell. Pass snapToGrid a number for a lattice of your own, or true to lock it to backgroundGap — then backgroundPattern shows the very grid nodes snap to, on all four sides:
---
import { Flow, FlowNode, FlowPort } from "../../index";
import { css } from "@pindoba/styled-system/css";
// Orthogonal routing on a visible grid: `snapToGrid` (no number) locks the
// snap lattice to `backgroundGap`, so positions AND sizes land on the very
// pattern that is drawn; arrowheads mark direction and the dashed march marks
// the live path. The direct ingest → publish edge is `smart`: it routes
// AROUND the node in its way instead of through it — drag Transform about
// and watch the detour re-plan.
// Edges are resolved server-side and re-routed by the boot controller as
// nodes move.
const nodes = [
{ id: "ingest", x: 40, y: 40, width: 140, height: 60, label: "Ingest" },
{
id: "transform",
x: 260,
y: 40,
width: 140,
height: 60,
label: "Transform",
},
{ id: "publish", x: 480, y: 40, width: 140, height: 60, label: "Publish" },
];
const edges = [
{
id: "e1",
source: "ingest",
target: "transform",
marker: "arrow" as const,
lineStyle: "dashed" as const,
},
{
id: "e2",
source: "transform",
target: "publish",
marker: "arrow" as const,
animated: true,
},
{
id: "e3",
source: "ingest",
target: "publish",
marker: "arrow" as const,
edgePath: "smart" as const,
label: "audit",
},
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow
{nodes}
{edges}
edgePath="smoothstep"
snapToGrid
backgroundGap={20}
backgroundPattern="grid"
>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
>
<div class={card}>{node.label}</div>
<Fragment slot="ports">
<FlowPort side="start" kind="input" />
<FlowPort side="end" kind="output" />
</Fragment>
</FlowNode>
))
}
</Flow>
</div>The annotated graph below puts it all on the edge data: a label at each midpoint (chipped by default), a markerStart="circle" origin paired with an arrow tip, a bold weight on the main path, and a per-edge edgePath="straight" override on the bypass while the rest of the flow stays bezier:
---
import { Flow, FlowNode, FlowPort } from "../../index";
import { css } from "@pindoba/styled-system/css";
// Everything an edge can say rides on its data: a `label` at the midpoint
// (server-rendered, re-centred by the boot controller as edges re-route),
// `markerStart`/`marker` tips, a `weight` axis, and a per-edge `edgePath`
// override that reroutes the bypass straight while the rest stays bezier.
const nodes = [
{ id: "extract", x: 40, y: 40, width: 140, height: 56, label: "Extract" },
{ id: "enrich", x: 260, y: 150, width: 140, height: 56, label: "Enrich" },
{ id: "store", x: 480, y: 40, width: 140, height: 56, label: "Store" },
];
const edges = [
{
id: "e1",
source: "extract",
target: "enrich",
label: "validate",
markerStart: "circle" as const,
marker: "arrow" as const,
},
{
id: "e2",
source: "enrich",
target: "store",
label: "publish",
marker: "arrow" as const,
weight: "bold" as const,
},
{
id: "e3",
source: "extract",
target: "store",
label: "bypass",
marker: "arrow" as const,
edgePath: "straight" as const,
},
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow {nodes} {edges}>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
>
<div class={card}>{node.label}</div>
<Fragment slot="ports">
<FlowPort side="start" kind="input" />
<FlowPort side="end" kind="output" />
</Fragment>
</FlowNode>
))
}
</Flow>
</div><FlowControls> is the stock cluster for the controls slot — zoom in/out, fit view, reset. The buttons are presentational: they carry data-flow-action and the controller delegates their clicks, so the same cluster works in plain Astro. minZoom/maxZoom clamp the reachable range and onViewportChange reports every pan and zoom.
---
import { Flow, FlowControls, FlowNode } from "../../index";
import { css } from "@pindoba/styled-system/css";
// The stock control cluster: zoom in/out, fit view, reset. The buttons carry
// `data-flow-action` and the boot controller delegates their clicks — no
// island needed.
const nodes = [
{ id: "a", x: 40, y: 40, width: 120, height: 56 },
{ id: "b", x: 300, y: 150, width: 120, height: 56 },
];
const edges = [
{ id: "e1", source: "a", target: "b", marker: "arrow" as const },
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow {nodes} {edges} minZoom={0.5} maxZoom={2}>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
>
<div class={card}>{node.id}</div>
</FlowNode>
))
}
<FlowControls slot="controls" />
</Flow>
</div>The whole imperative surface — fitView, centerOnNode, zoomIn/zoomOut, resetView, getViewport/setViewport, selectAll, the state atoms — is reachable in every framework: Svelte exports it from the component instance as flow, React exposes it on ref, Vue on the template ref’s .flow, and the Astro boot stashes it on the root element as .flow (alongside the flow:* CustomEvents that mirror the callbacks).
Persisting a diagram is two props: save what onViewportChange reports and pass it back as defaultViewport — or set fitView to frame every node on mount (it wins when both are given). showMinimap adds a corner overview — node rectangles plus a viewport indicator, fitted to whichever is larger — that you can click or drag to jump around a large graph.
readOnly is the inspection mode: panning, zooming and selecting stay available, but nothing edits — no node drags, no resizes, no connections, no Delete. Compare disabled, which turns everything off.
The editing tier composes into a full graph editor without leaving the controlled contract — everything below still round-trips through onNodesChange and your own handlers. Select the resizable node and pull a corner handle, or select the task node and use the toolbar that appears above it; drag the group and its child comes along, and drag any node slowly past a neighbour to watch the alignment guides catch it. The minimap jumps around the graph, and grabbing an edge near a tip detaches it for onReconnect to rewire.
---
import { Flow, FlowNode, FlowPort } from "../../index";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
// The whole editing tier in one graph: a minimap to jump around, alignment
// guides that snap a drag onto neighbours, a resizable node, a toolbar that
// appears above the selected node, and a parent group that carries its child.
// The graph is static here — reconnection still works through the boot
// controller, which surfaces the drop as a `flow:reconnect` CustomEvent.
interface EditorNode {
id: string;
x: number;
y: number;
width: number;
height: number;
label: string;
parent?: string;
resizable?: boolean;
}
const nodes: EditorNode[] = [
{
id: "group",
x: 40,
y: 40,
width: 200,
height: 150,
label: "Group — drag me",
},
{
id: "child",
x: 70,
y: 110,
width: 140,
height: 56,
label: "Child",
parent: "group",
},
{
id: "notes",
x: 320,
y: 40,
width: 170,
height: 72,
label: "Resizable",
resizable: true,
},
{ id: "task", x: 320, y: 180, width: 170, height: 60, label: "Task" },
];
const edges = [
{
id: "e1",
source: "child",
target: "notes",
marker: "arrow" as const,
sourcePort: "end" as const,
targetPort: "start" as const,
},
{
id: "e2",
source: "notes",
target: "task",
marker: "arrow" as const,
sourcePort: "end" as const,
targetPort: "start" as const,
},
];
const frame = css({
width: "100%",
aspectRatio: "1 / 1",
// px, not rem: the docs site sets a 10px root, so a rem value here
// would render at a different size than in a consuming app.
maxHeight: "480px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "sm",
overflow: "hidden",
});
const card = css({
height: "100%",
padding: "sm",
fontSize: "xs",
background: "neutral.surface.peak",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "neutral.border.muted",
borderRadius: "xs",
});
---
<div class={frame}>
<Flow {nodes} {edges} fitView showMinimap alignmentGuides>
{
nodes.map((node) => (
<FlowNode
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
parent={node.parent}
resizable={node.resizable}
>
<div class={card}>{node.label}</div>
{node.id !== "group" && (
<Fragment slot="ports">
<FlowPort side="start" kind="input" />
<FlowPort side="end" kind="output" />
</Fragment>
)}
{node.id === "task" && (
<Fragment slot="toolbar">
<Button size="xs" emphasis="secondary" feedback="danger">
Delete
</Button>
</Fragment>
)}
</FlowNode>
))
}
</Flow>
</div>Click a node to select it — it gains an accent outline; Shift-click to extend. Pressing an already-selected node keeps the whole selection, so dragging one of several selected nodes moves them all — and the drag delta is snapped rather than each node individually, which keeps a multi-node selection rigid instead of collapsing its internal spacing onto the grid.
Dragging empty space rubber-band selects (marqueeMode="touch" takes anything grazed; "contain" requires full enclosure; Shift extends instead of replacing). Escape clears the selection, Cmd/Ctrl+A selects everything, and a click on empty space deselects.
Every node is a focusable role="option" inside a multiselectable listbox layer, so the graph is operable without a pointer: Tab reaches each node, Enter/Space toggles its selection (with Shift it extends), and aria-selected mirrors the live selection in every framework.
Arrow keys nudge the selected nodes (by the snap grid when one is set, ×4 with Shift) and pan the surface when nothing is selected. +/- zoom and 0 resets. Delete/Backspace reports the selected nodes and edges through onDelete — the flow is controlled, so it never removes anything itself. Escape cancels the gesture in flight: an aborted node drag snaps back to where it started, an aborted marquee restores the previous selection, an aborted connection evaporates. Keystrokes inside an input or other editable content in a node body are never hijacked — including the zoom characters.
The flow is controlled, so features that sound big are just data patterns:
Auto-layout — run your nodes/edges through a layout library and pass the result back. With dagre:
import dagre from "@dagrejs/dagre";
function layout(nodes: NodeBox[], edges: FlowEdgeData[]): NodeBox[] {
const g = new dagre.graphlib.Graph().setGraph({ rankdir: "LR" });
g.setDefaultEdgeLabel(() => ({}));
for (const n of nodes) g.setNode(n.id, { width: n.width, height: n.height });
for (const e of edges) g.setEdge(e.source, e.target);
dagre.layout(g);
return nodes.map((n) => {
const { x, y } = g.node(n.id);
// dagre positions centres; the flow positions top-left corners.
return { ...n, x: x - n.width / 2, y: y - n.height / 2 };
});
}
Call it from a button (nodes = layout(nodes, edges)) and optionally flow.fitView() after.
Undo/redo — because every mutation flows through onNodesChange/onConnect/onReconnect/onDelete, history is a stack of { nodes, edges } snapshots:
const past: Array<{ nodes: NodeBox[]; edges: FlowEdgeData[] }> = [];
function commit(next: { nodes: NodeBox[]; edges: FlowEdgeData[] }) {
past.push({ nodes, edges }); // snapshot BEFORE applying
({ nodes, edges } = next);
}
function undo() {
const prev = past.pop();
if (prev) ({ nodes, edges } = prev);
}
Selection follows the graph: remove a node or an edge and its id drops out of $selection/$edgeSelection, with the matching onSelectionChange/onEdgeSelectionChange firing, so a Delete can never ask you to remove something that is already gone. A graph edited mid-gesture is safe for the same reason — a node deleted while a drag is in flight is not resurrected by the drag, and one added is not dropped by it.
Node ids must be unique: an id is the only handle the drag anchor, the edge endpoints, the selection and the DOM mirror have on a node, so two nodes sharing one move together. Development builds warn once, naming the id.
Snapshot on gesture END (the first onNodesChange after a pointerup, or simply debounce) rather than every drag frame. Copy/paste is the same idea: serialize the selected subset of nodes/edges (the ids are in $selection/$edgeSelection or your onSelectionChange mirror), then re-insert with fresh ids at an offset.
backgroundPattern paints a dots, grid or cross surface with CSS gradients, so it inherits the theme and costs no request. The pattern lives on the untransformed viewport and is offset and scaled by hand as the surface moves — tiling it on the transformed content instead would require an element sized to the whole unbounded graph.
The grid is orientation, not content, and it behaves that way: its color is the border token at a fraction of its opacity rather than full strength, zooming out re-quantizes it (marks never pack closer than ~14px — you see every 2nd, then 4th gridline, still aligned with the content grid), and the whole pattern fades away as the surface recedes toward minimum zoom instead of dissolving into noise.
falseShow edge/centre alignment hairlines while dragging, and snap onto them when within reach.
falseArrange the graph with the layered auto-layout once on mount, replacing the positions the data carried (reported through `onNodesChange`). Pass `LayoutOptions` to tune direction and spacing.
24Spacing of the background pattern, in content px.
"dots"The repeating surface pattern.
The `<FlowNode>` children, in any order.
"loose"Whether port `kind`s are enforced. `strict` only wires an `output` into an `input`; `loose` lets any port reach any other.
Optional control cluster, rendered outside the transformed surface.
Initial pan and zoom — restore a viewport saved from `onViewportChange`.
falseDisable all interaction.
"reject-undirected"How an existing edge blocks a new one between the same ports. `reject-undirected` also refuses the REVERSE of an existing edge, so A→B followed by B→A can't stack two curves along the same line.
Optional label rendered at each edge's midpoint.
"pill"Visual of every edge's midpoint label. `pill` gives each label the chip it needs to stay readable where it crosses its own line; `plain` strips the chip and leaves the positioning, for labels you paint yourself. Retune the chip in place by redefining the `--flow-label-pad-x/pad-y/ radius/bg/border/color` vars through `passThrough.edgeLabel.style`.
"bezier"How edges are routed. `smart` is `smoothstep` with collision avoidance — routes detour around nodes instead of cutting through them.
Connections between nodes.
Bound reference to the root element (`bind:this`).
"neutral"Semantic palette for the surface, marquee and edges.
falseFrame every node once on mount (after `defaultViewport`, if both are given, fitting wins).
Whether a proposed connection is allowed, on top of the declarative rules. The third argument describes both ends (side, offset, kind), so a rule can be port-aware.
"Graph flow"Accessible label for the flow region.
"touch"Whether a marquee takes nodes it merely grazes, or only fully enclosed ones.
Cap on edges per port, for ports that declare no `maxConnections` of their own. Unlimited when omitted.
4Highest zoom the user can reach.
0.2Lowest zoom the user can reach.
Node positions, in content coordinates. The flow is controlled: it reports drags through `onNodesChange` and renders whatever you pass back.
Fired when a connection is completed; `detail` names the exact ports.
Fired when <kbd>Delete</kbd>/<kbd>Backspace</kbd> is pressed with nodes or edges selected. The flow never mutates the graph itself — remove them from your data and pass the result back.
Fired whenever the edge selection (edges are selected by clicking) changes.
Fired whenever node positions change.
Fired when an existing edge's endpoint is dragged onto a new node — update `source`/`target` in your data.
Fired whenever the selection changes.
Fired whenever the pan or zoom changes.
trueWhether dragging empty space pans the surface. With `selectionOnDrag` also on, panning moves to middle-drag and space-drag.
Per-slot style and HTML-attribute overrides.
falseInspection mode: panning, zooming and selecting stay available, but nothing can be edited — no node drags, no connections, no resize, no Delete. Compare `disabled`, which turns everything off.
`id`s of the currently selected nodes.
trueWhether dragging empty space rubber-band selects instead of panning.
falseOverview map in the corner: node rectangles plus the visible-viewport indicator; click or drag it to jump around a large graph.
0Grid the drag snaps to, in content px. `0` disables snapping, and `true` uses `backgroundGap` so nodes land exactly on the pattern you can see. The grabbed node is snapped and the rest of the selection follows by the same correction, so a multi-node selection keeps its internal spacing.
Plus all standard <div> HTML
attributes.
trueWhether the node can be dragged.
Whether a drag is in progress on this node.
Semantic palette override for this node — colors its selection outline and ports.
Height in content px. Auto when omitted.
Identity of the node.
Cap on edges touching this node, across all its ports. Unlimited when omitted.
`id` of a node this one belongs to. Dragging or nudging the parent moves its whole subtree; coordinates stay absolute.
Per-slot overrides.
Connection points declared as data. The node renders one `FlowPort` per descriptor, and edges that name no port float between them — re-picking the best-facing port as nodes move. Use the `ports` slot instead when the ports need custom content.
falseShow corner handles while selected; dragging one reports the new geometry through `onNodesChange`, like a move does.
Whether the node is part of the current selection. Leave unset to let the flow controller mirror its own selection into the DOM; pass a boolean to control it from your own state instead.
26Height of one socket row, in px. Published as `--flow-socket-row` so the rendered band and the anchor arithmetic stay in step — retune it here rather than in CSS.
Connection points declared as a structured BAND of rows, rendered below the node's body. Each row spans the node's full width, so its ports straddle the node's borders while their labels sit beside them in flow — labelled connections that reserve their own space instead of overlaying the body. Routing reads these the same way it reads `ports`, so an edge anchors on a row's glyph with nothing to flatten by hand.
Width in content px.
Position on the x axis, in content px.
Position on the y axis, in content px.
Accessible name. Falls back to a description of the side and kind.
"output"Whether the port accepts incoming connections or starts outgoing ones.
Optional visible label.
"free"How the port is positioned. `free` places it along its side with `offset`; `row` pins it to the inline edge of the socket row it sits in, vertically centred — the row's own edges are the node's borders.
Cap on edges terminating at this port. A drop onto a full port is refused and the preview flips to the danger palette. Unlimited when omitted.
0.5Position along its side, 0–1.
Distance in px from the far end of the port's side, used instead of `offset` when set. Socket rows travel this way: a row's position is known in px from the node's bottom before the node's height is.
Per-slot overrides.
"square"Silhouette of the connection point. Square by default — this flow is rectangular throughout; `dot` is opt-in.
Which edge of the node the port sits on.
"idle"Connection state, driving the glyph's fill.
falseMarching dashes, for an edge carrying live data.
Routing override for this edge — statically rendered (Astro) edges are re-routed in this shape as nodes move, instead of the flow-wide kind.
Semantic palette override for this edge.
Identity of the edge.
Size of this edge's label, when its content is rendered by a snippet or render prop rather than given as `label` text. Label placement fits a BOX, not a point — it is the width that decides whether a pill clears the nodes an edge runs between. Core estimates that box from `label`; rich content has no text to estimate from, so declare it here or the label is placed as a dimensionless point and can land on a node.
"solid"Stroke pattern of the visible line. `animated` implies dashes — the animation IS the dashes moving.
"none"Tip at the target endpoint (`arrow`, `circle`, `diamond`), for a directed edge. Positioned from the rendered path, so it follows re-routes as nodes move.
"none"Tip at the source endpoint — see `marker`.
Per-slot overrides.
The `d` attribute for the rendered path.
Whether the edge renders selected. Leave unset to let the flow controller mirror click-selection into the DOM; pass a boolean to control it from your own state instead.
`id` of the source node. When set together with `target`, the controller re-routes this edge as nodes move — this is how statically rendered (Astro) edges follow their nodes. Reactive frameworks recompute paths from data instead and can omit it.
0.5Position (0–1) of the source port along its side.
Port on the source node. Auto-chosen from node positions when omitted.
`id` of the target node — see `source`.
0.5Position (0–1) of the target port along its side.
Port on the target node. Auto-chosen from node positions when omitted.
"regular"Stroke thickness; selection thickens relative to it.
falseDisable every control button.
Per-slot overrides.