Flow

Pan-and-zoom canvas of draggable nodes joined by animated edges.

A canvas of frames joined by edges, that you pan, pinch and rearrange with a finger. An edge names the nodes it joins rather than coordinates, and works out which faces to leave and arrive on from where those nodes currently are — so a graph the user rearranges stays readable without anyone re-specifying anything.

Installation

Flow ships with the library — no separate install.

import { Flow, Frame, Badge, Text, Button, type FlowConnection } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add flow

Usage

<Flow fitViewOnMount>
  <Flow.Background variant="dots" />
  <Flow.Node id="db" position={{ x: 20, y: 40 }}>
    <Frame>…</Frame>
  </Flow.Node>
  <Flow.Node id="web" position={{ x: 120, y: 260 }}>
    <Frame>…</Frame>
  </Flow.Node>
  <Flow.Edge from="web" to="db" variant="smoothstep" dashed arrow />
  <Flow.Controls />
</Flow>

Composition

<Flow onConnect={…} onNodeDragEnd={…}>
  <Flow.Background />
  <Flow.Group id="…" label="…" position={…} size={…}>
    <Flow.Node id="…" position={…}>
      <Frame>…</Frame>
      <Flow.Handle id="out" position="bottom" type="source" />
    </Flow.Node>
  </Flow.Group>
  <Flow.Edge from="…" to="…" arrow />
  <Flow.Controls />
  <Flow.MiniMap />
</Flow>
  • Flow.Background — The grid behind everything — dots, lines or crosses. Rides the canvas, so it pans and zooms with the graph.
  • Flow.Node — One frame on the canvas. Its content is yours; a Frame is the usual answer.
  • Flow.Handle — A named port. Both the point an edge can be pinned to and the grip a new connection is dragged from.
  • Flow.Edge — A line between two nodes, routed as a curve, a stepped run or a straight line.
  • Flow.Group — A labelled box drawn around a region of the canvas, that moves the nodes inside it.
  • Flow.Controls — Zoom, fit and lock buttons. Sits outside the transform, so it stays put.
  • Flow.MiniMap — An overview of the whole graph with the visible region marked on it.

Examples

A node is a frame

A node positions and drags; what it looks like is yours. A Frame is the usual answer, since a node is a titled card of rows more often than it is anything else.

<Flow.Node id="ghost" position={{ x: 96, y: 250 }}>
  <Frame className="w-56">
    <Frame.Header className="flex-row items-center gap-3">
      <Frame.Media><PackageIcon size={20} /></Frame.Media>
      <Frame.Content>
        <Text weight="semibold">ghost-image</Text>
        <Text size="xs" muted>blog.temetro.com</Text>
      </Frame.Content>
    </Frame.Header>
    <Frame.Panel>
      <Frame.Row>
        <Frame.Content><Text size="sm" muted>Online</Text></Frame.Content>
      </Frame.Row>
    </Frame.Panel>
  </Frame>
</Flow.Node>

Edges name nodes, not coordinates

With no handle named, an edge picks its two faces from where the boxes currently sit — whichever axis they are further apart on wins — and picks again as they move. Drag a frame and the line leaves from a different side.

{/* Faces chosen from the layout, and re-chosen as the frames move. */}
<Flow.Edge from="web" to="db" />

{/* Stepped wiring with an arrowhead on the target. */}
<Flow.Edge from="web" to="db" variant="smoothstep" arrow />

{/* Dashed, for a dependency that is live rather than declared. */}
<Flow.Edge from="queue" to="worker" dashed animated arrow />

Pinning an edge to a port

For a diagram where the sides mean something — accepted out of one port, rejected out of another. nodeId.handleId pins an end, and two handles on the same face are kept apart by their offset. Four ports is where naming them starts to pay: router.retry says which one an edge means.

<Flow.Node id="router" position={{ x: 520, y: 30 }}>
  <Frame>…</Frame>
  <Flow.Handle id="in" position="left" type="target" offset={0.3} />
  <Flow.Handle id="retry" position="left" type="target" offset={0.78} />
  <Flow.Handle id="metrics" position="top" type="source" />
  <Flow.Handle id="logs" position="bottom" type="source" />
</Flow.Node>

<Flow.Edge from="index.out" to="router.in" variant="smoothstep" arrow />
<Flow.Edge from="deadletter.out" to="router.retry" dashed arrow />

Building a graph at runtime

The canvas keeps no list of its own. Adding a frame is adding to an array, and so is linking two of them.

const [nodes, setNodes] = useState([{ id: 'n1', x: 40, y: 40 }]);
const [links, setLinks] = useState<{ from: string; to: string }[]>([]);

<Flow minZoom={0.35}>
  {nodes.map((node) => (
    <Flow.Node key={node.id} id={node.id} position={{ x: node.x, y: node.y }}>
      <Frame>…</Frame>
    </Flow.Node>
  ))}
  {links.map((link) => (
    <Flow.Edge
      key={`${link.from}-${link.to}`}
      from={link.from}
      to={link.to}
      variant="smoothstep"
      arrow
    />
  ))}
</Flow>

Wiring it up by dragging

A drag from a source handle to a target handle reports a connection. The canvas never adds the edge itself — the graph is yours, so what a new connection means is yours too.

const [edges, setEdges] = useState<FlowConnection[]>([]);

<Flow
  onConnect={(connection) => setEdges((current) => [...current, connection])}
  isValidConnection={(connection) => connection.source !== connection.target}
>

  {edges.map((edge) => (
    <Flow.Edge
      key={`${edge.source}-${edge.target}`}
      from={edge.source}
      to={edge.target}
      arrow
    />
  ))}
</Flow>

Driving positions from outside

Pass position once and the canvas takes it from there. Keep it in state and pass it back, and the graph is yours to move — onNodeDragEnd reports where a frame was dropped.

const [positions, setPositions] = useState(initial);

<Flow onNodeDragEnd={(id, position) =>
  setPositions((current) => ({ ...current, [id]: position }))
}>
  {Object.entries(positions).map(([id, position]) => (
    <Flow.Node key={id} id={id} position={position}>…</Flow.Node>
  ))}
</Flow>

Versions

Infrastructure map

Three frames and the dependencies between them. Drag any of them and the edges re-route, leaving from whichever side is now the shorter way round.

<Flow.Edge from="ghost" to="db" variant="smoothstep" dashed animated arrow />
<Flow.Edge from="ghost" to="redis" variant="smoothstep" arrow />

Build pipeline

Stages running top to bottom, with only the edge into the running stage dashed — marking every edge says nothing about which one is live.

<Flow.Edge
  from={stage.id}
  to={next.id}
  variant="smoothstep"
  animated={index === running - 1}
  arrow
/>

Wiring it up

Drag from one port to another to create an edge. The canvas reports the connection; adding it is the screen's decision.

<Flow onConnect={(connection) => setEdges((current) => [...current, connection])}>

Mind map

Curved edges radiating from a centre, with no fixed sides — drag a branch across and the edge re-routes to the face that now makes sense.

<Flow.Edge from="core" to={branch.id} variant="bezier" />

Building a graph

One button adds a frame, the other adds one already wired to the last. Everything the canvas draws comes from two arrays the screen owns.

<Button onPress={() => add(false)}>Add a frame</Button>
<Button onPress={() => add(true)}>Add and link</Button>

Named ports

Edges pinned to handles instead of routed automatically, for a diagram where the sides mean something. One frame carries a port on every face, and two of them share a face at different offsets.

<Flow.Node id="router" position={{ x: 520, y: 30 }}>
  <Frame>…</Frame>
  <Flow.Handle id="in" position="left" type="target" offset={0.3} />
  <Flow.Handle id="retry" position="left" type="target" offset={0.78} />
  <Flow.Handle id="metrics" position="top" type="source" />
  <Flow.Handle id="logs" position="bottom" type="source" />
</Flow.Node>

<Flow.Edge from="index.out" to="router.in" variant="smoothstep" arrow />
<Flow.Edge from="deadletter.out" to="router.retry" dashed arrow />

Groups and a minimap

Two labelled boxes that move their contents with them, and an overview of the parts off screen. The edges join the frames — a group is a region, not something an edge attaches to.

<Flow.Group id="edge-tier" label="Edge" position={{ x: 0, y: 0 }} size={{ width: 196, height: 178 }}>
  <Flow.Node id="cdn" position={{ x: 14, y: 34 }}>…</Flow.Node>
</Flow.Group>

<Flow.Edge from="cdn" to="waf" variant="smoothstep" arrow />

API Reference

Flow

PropTypeDefaultDescription
classNamestring
defaultViewportFlowViewportWhere the canvas starts. zoom of 1 is one graph point per screen point.
minZoomnumber0.3Closest the canvas will zoom out.
maxZoomnumber2.5Closest it will zoom in.
panOnDragbooleantrueDrag the empty canvas to move it.
zoomOnPinchbooleantruePinch to zoom.
fitViewOnMountbooleanfalseFrame every node once they have all measured themselves. For a graph whose positions come from data and are not laid out against a known screen size.
fitViewPaddingnumber48Padding left around the graph when fitting, in screen points.
onViewportChange(viewport: FlowViewport) => voidThe canvas has moved or zoomed. Fired as it happens, on the JS thread.
onNodeDragEnd(id: string, position: FlowNodePosition) => voidA node was dropped somewhere new. The only time a drag reaches JavaScript.
onConnect(connection: FlowConnection) => voidA connection was drawn between two handles. The canvas never adds the edge itself — the graph is yours, so what a new connection means is yours too.
isValidConnection(connection: FlowConnection) => booleanRefuse a connection before onConnect sees it.

Flow.Background

PropTypeDefaultDescription
variant'dots' | 'lines' | 'cross' | 'none''dots'The mark repeated across the canvas.
gapnumber24Points between marks.
sizenumber1.6How big each mark is drawn.
colorstringMark colour. Defaults to a muted theme token.

Flow.Node

PropTypeDefaultDescription
idstringIdentifies the node to edges and to onNodeDragEnd. Must be unique.
positionFlowNodePositionWhere it starts, in graph coordinates.
classNamestring
draggablebooleantrueLet a finger move it.
selectedbooleanfalseDraw the selected ring.
onPress() => voidTapping the node — separate from dragging it.
accessibilityLabelstringSpoken name. Defaults to the node's id.

Flow.Handle

PropTypeDefaultDescription
idstring'default'Names the handle to an edge, as "nodeId.handleId".
positionFlowSide'right'Which face it sits on.
type'source' | 'target' | 'both''both'source starts connections, target receives them, both does either. A drag from a source can only land on a target, and the other way round.
offsetnumber0.5Where along the face, 0–1. For more than one handle on a side.
classNamestring
hiddenbooleanfalseDraw nothing. The handle still anchors edges and still accepts a drop.

Flow.Edge

PropTypeDefaultDescription
fromstringSource, as "nodeId" or "nodeId.handleId".
tostringTarget, same shape.
variantFlowEdgeVariant'bezier'How the edge is routed.
animatedbooleanfalseMark the edge as carrying something — a request, a build, a dependency that is live rather than declared. Draws it dashed.
dashedbooleanfalseDraw it broken rather than solid.
colorstringStroke colour. Defaults to the muted-foreground token — an edge is content rather than chrome, and the border token it would otherwise share with the nodes is, by design, barely there.
widthnumber2Stroke width in graph points.
arrowbooleanfalsePut an arrowhead on the target end.
fromSideFlowSideOverride the face it leaves from. Otherwise worked out from the layout.
toSideFlowSideOverride the face it arrives at.
radiusnumber8Corner radius for smoothstep.
gapnumber20How far the edge steps clear of a node before turning.
curvaturenumber0.25Curve strength for bezier.

Flow.Group

PropTypeDefaultDescription
idstringIdentifies the group, the same way a node's id does.
positionFlowNodePositionWhere the container sits.
size{ width: number; height: number }How big the container is. Children are positioned inside it.
labelstringCaption drawn along the top edge.
classNamestring
draggablebooleantrueMove the group, and everything in it, with a finger.

Flow.Controls

PropTypeDefaultDescription
classNamestring
zoombooleanShow the zoom in and out buttons.
fitbooleantrueShow the fit-to-graph button.
lockbooleantrueShow the lock, which freezes panning, zooming and dragging together.
stepnumber1.3How much one press of zoom in multiplies the scale by.

Flow.MiniMap

PropTypeDefaultDescription
classNamestring
position'top-left' | 'top-right' | 'bottom-left' | 'bottom-right''bottom-right'Which corner it sits in.
widthnumber120Box size in screen points.
heightnumber84
nodeColorstringColour of a node in the map. Defaults to the muted-foreground token.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Where the edges attach

An edge names nodes rather than coordinates. With no handle named it picks the two faces from where the boxes currently sit — whichever axis they are further apart on wins — and picks again as they move, so the attachment point travels around a frame as you drag it and the line leaves from whichever side is now the shorter way round. That is what keeps a hand-arranged graph readable without anyone re-specifying anything.

Name a handle instead (from="ingest.ok") and the edge stays pinned to that port however the frames move. Use it when the sides mean something — accepted out of one port, rejected out of another — and leave it off otherwise. A handle works out its own position from the node's box and the face it names rather than measuring itself: a handle that measured would be a frame behind the node it sits on, and the edge would trail its own port.

Routing

bezier is a curve leaving and arriving perpendicular to each face. smoothstep is an orthogonal run with rounded corners — the one that reads as wiring — and step is the same with hard corners. straight is a line, for a graph where the routing is not the point.

arrow puts a head on the target end, drawn as its own small triangle rather than through SVG's marker-end. dashed breaks the line up, and animated marks an edge as carrying something live rather than declared, which also draws it dashed.

Where the positions live

Every node's box is kept twice: on the UI thread, where a drag writes it every frame and the node's own transform reads it, and in React state, where the edges are rendered from it. A dragged frame therefore never lags the finger moving it, and the edges attached to it redraw as it goes.

The tempting design is one copy — everything on the UI thread, edges animating their own path strings, no renders at all. It does not draw. An animated SVG path in React Native reliably animates its d and nothing else, and only while nothing else about it is animated. So the edges are ordinary elements and cost a render per drag frame, which is a real cost and the right trade.

onNodeDragEnd reports where a frame was dropped. Positions are otherwise yours to leave alone: pass position once and the canvas takes it from there, or keep it in state and pass it back to drive nodes from outside.

Three gestures, one canvas

The pane pans and pinches, a node drags, and a handle draws a new connection. They are nested gesture detectors rather than one gesture doing three jobs, so the innermost thing under the finger wins — which is what a finger expects.

Flow.Controls is worth having even where pinch works: on a phone, pinching to a particular scale is imprecise, and framing a whole graph by hand is worse. fitViewOnMount does the same job once, for a graph whose positions come from data rather than from a screen you measured. lock freezes panning, zooming and dragging together, for a canvas being read rather than edited.

Connections are reported, not applied

onConnect is handed { source, sourceHandle, target, targetHandle } and nothing else happens. The canvas never adds an edge itself, because the graph is yours and so is what a new connection means — a validation, a request, an undo entry. isValidConnection refuses one before onConnect sees it.

A drop does not have to land on a handle. The nearest handle within reach wins, and failing that a drop anywhere on a node connects to the node — a finger is blunt, and the strict reading of the gesture is the wrong one on a small screen.

Groups

A Flow.Group is a labelled box drawn around a region of the canvas. Dragging it offsets every node inside it in the same worklet that moves the box, so a group of twenty frames costs one frame's work rather than twenty.

A group is not a coordinate space: a node inside one carries the same graph position as a node outside it, and edges join the frames rather than the boxes drawn around them.

On this page