component

Flow

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.

A Graph

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.

Trigger
Security Review
Human Review
---
// GENERATED from packages/demos/flow/graph.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type {
  FlowEdgeData,
  FlowPortDescriptor,
  NodeBox,
} from "@pindoba/core-flow";

const PORTS = [
  { side: "top", kind: "input" },
  { side: "start", kind: "input" },
  { side: "end", kind: "output" },
  { side: "bottom", kind: "output" },
] as FlowPortDescriptor[];
const LABEL = {
  trigger: "Trigger",
  review: "Security Review",
  gate: "Human Review",
} as Record<string, string>;
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",
});

const nodes = [
  { id: "trigger", x: 60, y: 60, width: 150, height: 62, ports: PORTS },
  { id: "review", x: 90, y: 300, width: 150, height: 62, ports: PORTS },
  { id: "gate", x: 420, y: 330, width: 150, height: 62, ports: PORTS },
] as NodeBox[];
const edges = [
  { id: "e1", source: "trigger", target: "review", marker: "arrow" },
  { id: "e2", source: "review", target: "gate", marker: "arrow" },
] as FlowEdgeData[];
---

<div class={frame} data-ir-root="flow/graph">
  <Flow nodes={nodes} edges={edges} data-ir="1">
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
        <div class={card}>
          <!--ir:4-->
          {LABEL[node.id]}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/graph.ts";

  document
    .querySelectorAll('[data-ir-root="flow/graph"]')
    .forEach((root) => hydrate([root]));
</script>

Nodes

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

Ports and Edges

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

Socket rows

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.

Approve plan
plan
≥ 0.8
approved
rejected
Ship it
in
Send back
in
---
// GENERATED from packages/demos/flow/sockets.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { FlowEdgeData, NodeBox } from "@pindoba/core-flow";

const body = {
  review: "Approve plan",
  ship: "Ship it",
  revise: "Send back",
} as Record<string, string>;
const edges = [
  { id: "e1", source: "review", target: "ship", marker: "arrow" },
  { id: "e2", source: "review", target: "revise", marker: "arrow" },
] as FlowEdgeData[];
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 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",
});

const nodes = [
  {
    id: "review",
    x: 40,
    y: 150,
    width: 190,
    height: 104,
    sockets: [
      { id: "yes", start: { label: "plan" }, end: { label: "approved" } },
      { id: "no", end: { label: "rejected" } },
    ],
  },
  {
    id: "ship",
    x: 340,
    y: 60,
    width: 150,
    height: 78,
    sockets: [{ start: { label: "in" } }],
  },
  {
    id: "revise",
    x: 340,
    y: 270,
    width: 150,
    height: 78,
    sockets: [{ start: { label: "in" } }],
  },
] as NodeBox[];
---

<div class={frame} data-ir-root="flow/sockets">
  <Flow nodes={nodes} edges={edges} data-ir="1">
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode
        {...node}
        passThrough={{ root: { style: nodeFrame } }}
        data-ir="3"
        data-ir-key={node.id}
      >
        {(node.sockets ?? [])
          .map((socket, index) => ({
            ...socket,
            id: socket.id ?? `row-${index}`,
            startLabel: socket.start?.label,
            endLabel: socket.end?.label,
          }))
          .map((row) => (
            <Fragment slot={`socket-${row.id}`}>
              {node.id === "review" && row.id === "yes" && (
                <span class={lane}>≥ 0.8</span>
              )}
            </Fragment>
          ))}
        <div class={card}>
          <!--ir:4-->
          {body[node.id]}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/sockets.ts";

  document
    .querySelectorAll('[data-ir-root="flow/sockets"]')
    .forEach((root) => hydrate([root]));
</script>

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.

Connection rules

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.

source
sink
---
// GENERATED from packages/demos/flow/connections.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode, FlowPort } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { FlowEdgeData, NodeBox } from "@pindoba/core-flow";

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",
});

const nodes = [
  { id: "source", x: 40, y: 120, width: 130, height: 72 },
  { id: "sink", x: 420, y: 120, width: 130, height: 72 },
] as NodeBox[];
const edges = [] as FlowEdgeData[];
---

<div class={frame} data-ir-root="flow/connections">
  <Flow nodes={nodes} edges={edges} data-ir="1">
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
        <Fragment slot="ports">
          <Fragment set:html="<!--ir:5-->" />
          {node.id === "source" ? (
            <>
              <FlowPort side="end" kind="output" offset={0.25} label="ok" />
              <FlowPort side="end" kind="output" offset={0.75} label="err" />
            </>
          ) : (
            <FlowPort side="start" kind="input" shape="dot" label="in" />
          )}
          <Fragment set:html="<!--/ir-->" />
        </Fragment>
        <div class={card}>
          <!--ir:4-->
          {node.id}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/connections.ts";

  document
    .querySelectorAll('[data-ir-root="flow/connections"]')
    .forEach((root) => hydrate([root]));
</script>

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 tone 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 flow runtime 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 runtime 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: tone 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

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:

Ingest
Transform
Publish
---
// GENERATED from packages/demos/flow/routing.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode, FlowPort } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { FlowEdgeData, NodeBox } from "@pindoba/core-flow";

const edges = [
  {
    id: "e1",
    source: "ingest",
    target: "transform",
    marker: "arrow",
    lineStyle: "dashed",
  },
  {
    id: "e2",
    source: "transform",
    target: "publish",
    marker: "arrow",
    animated: true,
  },
  {
    id: "e3",
    source: "ingest",
    target: "publish",
    marker: "arrow",
    edgePath: "smart",
    label: "audit",
  },
] as FlowEdgeData[];
const LABEL = {
  ingest: "Ingest",
  transform: "Transform",
  publish: "Publish",
} as Record<string, string>;
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",
});

const nodes = [
  { id: "ingest", x: 40, y: 40, width: 140, height: 60 },
  { id: "transform", x: 260, y: 40, width: 140, height: 60 },
  { id: "publish", x: 480, y: 40, width: 140, height: 60 },
] as NodeBox[];
---

<div class={frame} data-ir-root="flow/routing">
  <Flow
    nodes={nodes}
    edges={edges}
    edgePath="smoothstep"
    snapToGrid
    backgroundGap={20}
    backgroundPattern="grid"
    data-ir="1"
  >
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
        <Fragment slot="ports">
          <FlowPort side="start" kind="input" />
          <FlowPort side="end" kind="output" />
        </Fragment>
        <div class={card}>
          <!--ir:4-->
          {LABEL[node.id]}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/routing.ts";

  document
    .querySelectorAll('[data-ir-root="flow/routing"]')
    .forEach((root) => hydrate([root]));
</script>

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:

Extract
Enrich
Store
---
// GENERATED from packages/demos/flow/annotations.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode, FlowPort } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { FlowEdgeData, NodeBox } from "@pindoba/core-flow";

const edges = [
  {
    id: "e1",
    source: "extract",
    target: "enrich",
    label: "validate",
    markerStart: "circle",
    marker: "arrow",
  },
  {
    id: "e2",
    source: "enrich",
    target: "store",
    label: "publish",
    marker: "arrow",
    weight: "bold",
  },
  {
    id: "e3",
    source: "extract",
    target: "store",
    label: "bypass",
    marker: "arrow",
    edgePath: "straight",
  },
] as FlowEdgeData[];
const LABEL = {
  extract: "Extract",
  enrich: "Enrich",
  store: "Store",
} as Record<string, string>;
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",
});

const nodes = [
  { id: "extract", x: 40, y: 40, width: 140, height: 56 },
  { id: "enrich", x: 260, y: 150, width: 140, height: 56 },
  { id: "store", x: 480, y: 40, width: 140, height: 56 },
] as NodeBox[];
---

<div class={frame} data-ir-root="flow/annotations">
  <Flow nodes={nodes} edges={edges} data-ir="1">
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
        <Fragment slot="ports">
          <FlowPort side="start" kind="input" />
          <FlowPort side="end" kind="output" />
        </Fragment>
        <div class={card}>
          <!--ir:4-->
          {LABEL[node.id]}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/annotations.ts";

  document
    .querySelectorAll('[data-ir-root="flow/annotations"]')
    .forEach((root) => hydrate([root]));
</script>

Controls and the Viewport

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

a
b
---
// GENERATED from packages/demos/flow/controls.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode, FlowControls } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { NodeBox } from "@pindoba/core-flow";

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",
});

const nodes = [
  { id: "a", x: 40, y: 40, width: 120, height: 56 },
  { id: "b", x: 300, y: 150, width: 120, height: 56 },
] as NodeBox[];
---

<div class={frame} data-ir-root="flow/controls">
  <Flow
    nodes={nodes}
    edges={[{ id: "e1", source: "a", target: "b", marker: "arrow" }]}
    minZoom={0.5}
    maxZoom={2}
    data-ir="1"
  >
    <FlowControls slot="controls" />
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
        <div class={card}>
          <!--ir:4-->
          {node.id}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/controls.ts";

  document
    .querySelectorAll('[data-ir-root="flow/controls"]')
    .forEach((root) => hydrate([root]));
</script>

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

Editing

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.

Group — drag me
Child
Resizable
Task
---
// GENERATED from packages/demos/flow/editor.demo.tsx — edit the source, not this file.
import Flow from "@pindoba/astro-flow";
import { FlowNode } from "@pindoba/astro-flow";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import type { FlowEdgeData, NodeBox } from "@pindoba/core-flow";

const LABEL = {
  group: "Group — drag me",
  child: "Child",
  notes: "Resizable",
  task: "Task",
} as Record<string, string>;
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",
});

const nodes = [
  { id: "group", x: 40, y: 40, width: 200, height: 150 },
  { id: "child", x: 70, y: 110, width: 140, height: 56, parent: "group" },
  { id: "notes", x: 320, y: 40, width: 170, height: 72 },
  { id: "task", x: 320, y: 180, width: 170, height: 60 },
] as NodeBox[];
const edges = [
  {
    id: "e1",
    source: "child",
    target: "notes",
    marker: "arrow",
    sourcePort: "end",
    targetPort: "start",
  },
  {
    id: "e2",
    source: "notes",
    target: "task",
    marker: "arrow",
    sourcePort: "end",
    targetPort: "start",
  },
] as FlowEdgeData[];
---

<div class={frame} data-ir-root="flow/editor">
  <Flow
    nodes={nodes}
    edges={edges}
    fitView
    showMinimap
    alignmentGuides
    data-ir="1"
  >
    <Fragment set:html="<!--ir:2-->" />
    {nodes.map((node) => (
      <FlowNode
        {...node}
        resizable={node.id === "notes"}
        ports={
          node.id === "group"
            ? undefined
            : [
                { side: "start", kind: "input" },
                { side: "end", kind: "output" },
              ]
        }
        data-ir="3"
        data-ir-key={node.id}
      >
        {node.id === "task" && (
          <Button
            slot="toolbar"
            size="xs"
            emphasis="secondary"
            tone="danger"
            data-ir="5"
          >
            Delete
          </Button>
        )}
        <div class={card}>
          <!--ir:4-->
          {LABEL[node.id]}
          <!--/ir-->
        </div>
      </FlowNode>
    ))}
    <Fragment set:html="<!--/ir-->" />
  </Flow>
</div>

<script>
  import { hydrate } from "./_client/editor.ts";

  document
    .querySelectorAll('[data-ir-root="flow/editor"]')
    .forEach((root) => hydrate([root]));
</script>

Selection

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.

Keyboard and Accessibility

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.

Recipes

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.

Card

Flow is card-optional: it renders the bare canvas by default, and a card object frames it in a Card:

  • card.heading / card.subheading / card.eyebrow / card.leading fill the card header, and card.trailing holds the graph’s toolbar actions (re-run, export). In Astro, rich header content goes in the card-trailing (and other card-*) slots.
  • card.footer adds an optional footer (a run summary, a legend).
  • The canvas is the card content, edge to edge, and it fills the card height the header and footer leave. Give the component a height (style="height: 360px", a sized parent) and the whole card takes it. Because every flow layer is absolutely positioned, an auto-height card would give the canvas no height at all, so a framed flow keeps a 320px floor. Change it with --flow-min-height.
  • The controls cluster and the minimap stay on the canvas.

A rendered heading labels the flow region (aria-labelledby) unless you set label. The viewport keeps its role="application", its keyboard handling and its pointer math. Node drags, marquee selection and connections are all measured on the flow viewport. The canvas keeps its surface.ground backdrop inside the card, so nodes still read as lifted off it.

Release pipeline

main · run #482

Build2m 14s
Unit tests412 passed
LintClean
DeployWaiting
Last run 6 minutes ago
---
// GENERATED from packages/demos/flow/card.demo.tsx — edit the source, not this file.
import Button from "@pindoba/astro-button";
import Flow from "@pindoba/astro-flow";
import { FlowNode, FlowControls } from "@pindoba/astro-flow";
import { css } from "@pindoba/styled-system/css";
import type { NodeBox } from "@pindoba/core-flow";

const stages = {
  build: { label: "Build", status: "2m 14s" },
  unit: { label: "Unit tests", status: "412 passed" },
  lint: { label: "Lint", status: "Clean" },
  deploy: { label: "Deploy", status: "Waiting" },
} as Record<string, { label: string; status: string }>;
const edges = [
  { id: "build-unit", source: "build", target: "unit", marker: "arrow" },
  { id: "build-lint", source: "build", target: "lint", marker: "arrow" },
  { id: "unit-deploy", source: "unit", target: "deploy", marker: "arrow" },
  { id: "lint-deploy", source: "lint", target: "deploy", marker: "arrow" },
] as const;
const stage = css({
  display: "flex",
  flexDirection: "column",
  justifyContent: "center",
  height: "100%",
  paddingInline: "sm",
  background: "neutral.surface.peak",
  borderWidth: "1px",
  borderStyle: "solid",
  borderColor: "neutral.border",
  borderRadius: "sm",
});
const label = css({ textStyle: "body.sm", fontWeight: "medium" });
const status = css({ textStyle: "body.xs", color: "neutral.text.muted" });

const nodes = [
  { id: "build", x: 0, y: 64, width: 140, height: 56 },
  { id: "unit", x: 220, y: 0, width: 140, height: 56 },
  { id: "lint", x: 220, y: 128, width: 140, height: 56 },
  { id: "deploy", x: 440, y: 64, width: 140, height: 56 },
] as NodeBox[];
---

<Flow
  nodes={nodes}
  edges={[...edges]}
  fitView
  class={css({ height: "360px" })}
  card={{
    heading: "Release pipeline",
    subheading: "main · run #482",
    footer: { leading: "Last run 6 minutes ago" },
  }}
  data-ir-root="flow/card"
  data-ir="1"
>
  <FlowControls slot="controls" />
  <Button slot="card-trailing" size="sm" emphasis="secondary">
    Re-run
  </Button>
  <Fragment set:html="<!--ir:2-->" />
  {nodes.map((node) => (
    <FlowNode {...node} data-ir="3" data-ir-key={node.id}>
      <div class={stage}>
        <span class={label}>
          <!--ir:4-->
          {stages[node.id]?.label}
          <!--/ir-->
        </span>
        <span class={status}>
          <!--ir:5-->
          {stages[node.id]?.status}
          <!--/ir-->
        </span>
      </div>
    </FlowNode>
  ))}
  <Fragment set:html="<!--/ir-->" />
</Flow>

<script>
  import { hydrate } from "./_client/card.ts";

  document
    .querySelectorAll('[data-ir-root="flow/card"]')
    .forEach((root) => hydrate([root]));
</script>

Bare by default and card={false}

Without a card prop (or with card={false}) Flow renders exactly the pre-card DOM: the flow root (data-component="flow") is the root. When framed, the root is the Card (data-component="card", data-card-kind="flow"), and your id, class, style and other HTML attributes land on it. The flow root, with its serialized config and the Astro data-flow-boot marker, sits inside the card content, and passThrough.root still styles it. passThrough.card reaches the Card’s parts. In Astro, the flow:* events bubble to the card root and the hydrated controller is exposed there too (card.flow). The bindable element (Svelte) still points at the flow root.

Background

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.

background picks the canvas fill with the design system’s surface vocabulary: "surface.ground" (default) paints the floor the nodes lift off, and "transparent" paints nothing, so a static diagram embedded in a card or panel sits on that surface instead of a canvas of its own. It is independent of the pattern: pair background="transparent" with backgroundPattern="none" for a bare diagram, or keep a pattern over the host’s surface.

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.

  • Components
  • Blocks