Flow
A canvas of nodes joined by edges, that you pan, zoom and rearrange.
'use client';
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Flow } from '@/components/panelui/flow';
/** An article on its way to being published. */
const STAGES = [
{ id: 'draft', name: 'Draft', detail: '1,840 words' },
{ id: 'edit', name: 'Edit', detail: 'Two rounds' },
{ id: 'check', name: 'Fact check', detail: '14 claims' },
{ id: 'legal', name: 'Legal read', detail: 'One quote to clear' },
{ id: 'schedule', name: 'Schedule', detail: 'Thursday, 09:00' },
{ id: 'publish', name: 'Publish', detail: 'Site and newsletter' },
];
const TAG = {
done: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-(--success)',
live: 'bg-[color-mix(in_oklab,var(--info)_14%,transparent)] text-(--info)',
waiting: 'bg-muted text-muted-foreground',
};
/**
* Whether the canvas has room to lay the graph out across. It is the canvas
* that is measured and not the window: in a page with a sidebar the two are
* far apart.
*/
function useRoom(least: number) {
const [wide, setWide] = useState(true);
const watch = useCallback(
(node: HTMLElement | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
observer.observe(node);
return () => observer.disconnect();
},
[least]
);
return [wide, watch] as const;
}
export default function FlowPipeline() {
// The stage being worked on. Everything before it is done.
const [at, setAt] = useState(1);
const [wide, watch] = useRoom(720);
const columns = wide ? 3 : 2;
// The stages run along a row and back along the next, like a snake.
const place = (index: number) => {
const row = Math.floor(index / columns);
const along = index % columns;
const column = row % 2 ? columns - 1 - along : along;
return { x: column * (wide ? 340 : 216), y: row * (wide ? 230 : 160) };
};
return (
<div ref={watch} className="relative h-[34rem] w-full">
<Flow
// Laid out afresh, and fitted afresh, when the room for it changes.
key={columns}
fitViewOnMount
fitViewPadding={wide ? 48 : 20}
label="Article pipeline"
className="h-full rounded-none border-0"
>
<Flow.Background variant="lines" gap={28} />
{STAGES.map((stage, index) => {
const state = index < at ? 'done' : index === at ? 'live' : 'waiting';
return (
<Flow.Node
key={stage.id}
id={stage.id}
position={place(index)}
className={wide ? 'w-52' : 'w-44'}
>
<div className="flex items-center justify-between gap-3 border-b px-4 py-2.5">
<span className="text-sm text-muted-foreground">Stage {index + 1}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG[state]}`}>
{state === 'done' ? 'Done' : state === 'live' ? 'Now' : 'Waiting'}
</span>
</div>
<div className="px-4 py-3">
<p className="text-sm font-medium">{stage.name}</p>
<p className="truncate text-sm text-muted-foreground">{stage.detail}</p>
</div>
</Flow.Node>
);
})}
{STAGES.slice(1).map((stage, index) => (
<Flow.Edge
key={stage.id}
from={STAGES[index].id}
to={stage.id}
variant="smoothstep"
gap={10}
arrow
// Only the edge into the stage being worked on is marching.
animated={index + 1 === at}
color={index + 1 === at ? 'info' : undefined}
/>
))}
{/* Fewer buttons where the graph needs the room. */}
<Flow.Controls fit={wide} lock={wide} />
</Flow>
<div className="absolute bottom-3 left-3 flex gap-2">
<Button variant="outline" size="sm" disabled={at === 0} onClick={() => setAt(at - 1)}>
Back a stage
</Button>
<Button size="sm" disabled={at === STAGES.length - 1} onClick={() => setAt(at + 1)}>
Advance
</Button>
</div>
</div>
);
}Use it to show how things are connected and let somebody move them about: the stages of a pipeline, an automation, the parts of a system. Nodes are boxes with your own contents, edges join them, and the canvas is panned and zoomed.
It has the same parts and props as the app library's Flow, so a graph written for one reads the same in the other.
It lays nothing out for you. Every node is given a position, so for a graph whose shape comes from data you need to work the positions out first. For steps in a fixed order with nothing to rearrange, a list of steps is the simpler thing.
Installation
npx shadcn@latest add https://panelui.dev/r/web/flow.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/flow.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/flow.jsonbunx shadcn@latest add https://panelui.dev/r/web/flow.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/flow instead of @/components/panelui/flow. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install tailwind-variantspnpm add tailwind-variantsyarn add tailwind-variantsbun add tailwind-variantsCopy the source into components/panelui/flow/, one file for each below:
Usage
import { Flow } from "@/components/panelui/flow"<Flow fitViewOnMount>
<Flow.Background />
<Flow.Node id="draft" position={{ x: 40, y: 20 }}>Draft</Flow.Node>
<Flow.Node id="edit" position={{ x: 40, y: 150 }}>Edit</Flow.Node>
<Flow.Edge from="draft" to="edit" arrow />
<Flow.Controls />
</Flow>Composition
<Flow>
<Flow.Background />
<Flow.Node id="…" position={…}>
…
<Flow.Handle />
</Flow.Node>
<Flow.Group id="…" position={…} />
<Flow.Edge from="…" to="…" />
<Flow.Controls />
<Flow.MiniMap />
</Flow>Flow.Background— The pattern behind the graph: dots, lines or crosses. It moves and scales with the graph.Flow.Node— A box on the graph.idis what edges call it,positionis where it starts, and its children are yours.Flow.Handle— A dot on a node's edge that a connection can be drawn from or dropped on. Give it anidto join an edge to it by name.Flow.Edge— A line from one node to another: a curve, a routed line or a straight one, optionally dashed, marching and with an arrowhead.Flow.Group— A dashed box with a label, round things that belong together. It is dragged as one and an edge can be joined to it.Flow.Controls— Buttons to zoom, fit everything in view and lock the canvas.Flow.MiniMap— The whole graph, small, with the part in view marked on it.
Examples
Wiring it up
A Flow.Handle is somewhere to start a connection. Drag from one to another node and onConnect is told which two; the edges are yours to keep. isValidConnection refuses the ones you do not want, here a second wire between the same two steps.
'use client';
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Flow, type FlowConnection } from '@/components/panelui/flow';
/** Each step, and where it sits: across a wide screen, down a narrow one. */
const NODES = [
{
id: 'form',
name: 'Sign-up form',
across: { x: 0, y: 90 },
down: { x: 0, y: 0 },
},
{
id: 'verify',
name: 'Verify email',
across: { x: 320, y: 0 },
down: { x: 150, y: 80 },
},
{
id: 'welcome',
name: 'Welcome message',
across: { x: 320, y: 180 },
down: { x: 0, y: 160 },
},
{
id: 'crm',
name: 'Add to CRM',
across: { x: 680, y: 90 },
down: { x: 150, y: 240 },
},
];
/**
* Whether the canvas has room to lay the graph out across. It is the canvas
* that is measured and not the window: in a page with a sidebar the two are
* far apart.
*/
function useRoom(least: number) {
const [wide, setWide] = useState(true);
const watch = useCallback(
(node: HTMLElement | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
observer.observe(node);
return () => observer.disconnect();
},
[least]
);
return [wide, watch] as const;
}
const START: FlowConnection[] = [{ source: 'form', target: 'verify' }];
export default function FlowWiring() {
const [wires, setWires] = useState(START);
const [wide, watch] = useRoom(720);
// A wire leaves one face of a step and arrives at the opposite one.
const out = wide ? 'right' : 'bottom';
const into = wide ? 'left' : 'top';
const joined = (a: FlowConnection, b: FlowConnection) =>
a.source === b.source && a.target === b.target;
return (
<div ref={watch} className="relative h-[26rem] w-full">
<Flow
// Fitted afresh when the layout changes.
key={wide ? 'across' : 'down'}
fitViewOnMount
fitViewPadding={wide ? 48 : 24}
label="Sign-up automation"
className="h-full rounded-none border-0"
// Two nodes are joined once. A second wire between them is refused.
isValidConnection={(connection) => !wires.some((wire) => joined(wire, connection))}
onConnect={(connection) => setWires((current) => [...current, connection])}
>
<Flow.Background />
{NODES.map((node) => (
<Flow.Node
key={node.id}
id={node.id}
position={wide ? node.across : node.down}
className="px-4 py-3"
>
<p className="whitespace-nowrap text-sm font-medium">{node.name}</p>
<Flow.Handle position={into} />
<Flow.Handle position={out} />
</Flow.Node>
))}
{wires.map((wire) => (
<Flow.Edge
key={`${wire.source}-${wire.target}`}
from={wire.source}
to={wire.target}
arrow
fromSide={out}
toSide={into}
/>
))}
<Flow.Controls lock={false} />
</Flow>
<div className="absolute bottom-3 left-3 flex max-w-[calc(100%-4.5rem)] items-center gap-3 rounded-xl border bg-card py-1.5 pe-1.5 ps-3 shadow-sm">
<p className="text-sm text-muted-foreground">
{wide ? 'Drag from a dot on one step to another step to join them. ' : null}
{wires.length} joined.
</p>
<Button variant="outline" size="sm" onClick={() => setWires(START)}>
Reset
</Button>
</div>
</div>
);
}Groups and a minimap
Flow.Group holds ordinary elements and moves as one. The edges are joined to the groups by their id. Flow.MiniMap shows where the view is on the whole graph.
'use client';
import {
ArchiveIcon,
DatabaseIcon,
GlobeIcon,
HardDriveIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react';
import { Flow } from '@/components/panelui/flow';
function Service({ icon, name, detail }: { icon: ReactNode; name: string; detail: string }) {
return (
<div className="flex w-48 items-center gap-3 rounded-xl border bg-card px-3 py-2.5 [&_svg]:size-4 [&_svg]:text-muted-foreground">
{icon}
<div>
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
</div>
);
}
/**
* Whether the canvas has room to lay the graph out across. It is the canvas
* that is measured and not the window: in a page with a sidebar the two are
* far apart.
*/
function useRoom(least: number) {
const [wide, setWide] = useState(true);
const watch = useCallback(
(node: HTMLElement | null) => {
if (!node) return;
const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= least));
observer.observe(node);
return () => observer.disconnect();
},
[least]
);
return [wide, watch] as const;
}
export default function FlowGroups() {
const [wide, watch] = useRoom(720);
// Side by side where there is room, and one under the other where there is not.
const from = wide ? 'right' : 'bottom';
const to = wide ? 'left' : 'top';
return (
// Taller where the groups are stacked, so they are not shrunk to fit.
<div ref={watch} className={wide ? 'h-[28rem] w-full' : 'h-[36rem] w-full'}>
<Flow
// Fitted afresh when the layout changes.
key={wide ? 'across' : 'down'}
fitViewOnMount
fitViewPadding={wide ? 48 : 24}
label="Where a request goes"
className="h-full rounded-none border-0"
>
<Flow.Background variant="dots" />
<Flow.Group
id="edge"
label="Edge"
position={{ x: 0, y: 0 }}
className="flex flex-col gap-2"
>
<Service icon={<GlobeIcon />} name="Cache" detail="96 locations" />
<Service icon={<ShieldCheckIcon />} name="Firewall" detail="Blocking 0.3%" />
</Flow.Group>
<Flow.Group
id="core"
label="Core"
position={wide ? { x: 330, y: 130 } : { x: 70, y: 200 }}
className="flex flex-col gap-2"
>
<Service icon={<ServerIcon />} name="Application" detail="Six instances" />
<Service icon={<DatabaseIcon />} name="Database" detail="One primary, two copies" />
</Flow.Group>
<Flow.Group
id="store"
label="Storage"
position={wide ? { x: 660, y: 0 } : { x: 0, y: 400 }}
className="flex flex-col gap-2"
>
<Service icon={<HardDriveIcon />} name="Uploads" detail="2.1 TB" />
<Service icon={<ArchiveIcon />} name="Backups" detail="Every six hours" />
</Flow.Group>
<Flow.Edge
from="edge"
to="core"
variant="smoothstep"
dashed
arrow
fromSide={from}
toSide={to}
/>
<Flow.Edge
from="core"
to="store"
variant="smoothstep"
dashed
arrow
fromSide={from}
toSide={to}
/>
<Flow.Controls position="top-left" lock={false} />
{/* The map is for a graph too big to see at once, which on a narrow screen this is not. */}
{wide ? <Flow.MiniMap position="bottom-right" /> : null}
</Flow>
</div>
);
}API Reference
Flow
| Prop | Type | Default | Description |
|---|---|---|---|
defaultViewport | FlowViewport | — | Where the canvas looks to begin with. |
minZoom | number | 0.3 | The furthest out it can be zoomed. |
maxZoom | number | 2.5 | The furthest in it can be zoomed. |
panOnDrag | boolean | true | Whether dragging the pane pans it. |
zoomOnPinch | boolean | true | Whether a pinch, or Control with the wheel, zooms it. |
fitViewOnMount | boolean | false | Fits every node into view once they have been measured. |
fitViewPadding | number | 48 | The room left round the nodes when fitting them, in pixels. |
onViewportChange | (viewport: FlowViewport) => void | — | Called as the canvas is panned or zoomed. |
onNodeDragEnd | (id: string, position: FlowNodePosition) => void | — | Called with a node's new position when a drag or a key press has moved it. |
onConnect | (connection: FlowConnection) => void | — | Called when a connection drawn from a handle lands on another node. |
isValidConnection | (connection: FlowConnection) => boolean | — | Decides whether a connection may land. Without it, any two different nodes may be joined. |
label | string | 'Diagram' | What a screen reader calls the canvas. |
Flow.Background
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'dots' | 'lines' | 'cross' | 'none' | 'bezier' | Dots, a grid of lines, small crosses, or nothing. |
gap | number | 20 | The distance from one mark to the next, in pixels at a zoom of 1. |
size | number | — | How big a mark is: a dot's radius, a line's thickness, half a cross's arm. |
className | string | — |
Flow.Node
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | What edges and onNodeDragEnd call this node. |
position | FlowNodePosition | — | Where its top left corner is, on the graph. |
draggable | boolean | true | Whether it can be dragged and moved with the arrow keys. |
selected | boolean | false | Draws it as the one that has been picked. |
onPress | () => void | — | Called when it is pressed without being dragged, or with Enter. |
onDelete | () => void | — | Called when Delete or Backspace is pressed on it. |
moveStep | number | — | How far one arrow key press moves it, in pixels. Shift moves four times as far. |
Flow.Group
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Written in the corner of the group. |
Flow.Handle
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | A name for this handle, so an edge can be joined to it and not only to the node. |
position | FlowSide | 'bottom-right' | Which face of the node it sits on. |
offset | number | — | How far along that face, from 0 to 1. |
Flow.Edge
| Prop | Type | Default | Description |
|---|---|---|---|
from | FlowEndpointReference | — | Where it leaves from: a node's id, or { node, handle }. |
to | FlowEndpointReference | — | Where it arrives. |
variant | FlowEdgeVariant | 'bezier' | A curve, a rounded right-angled route, a square one, or a straight line. |
animated | boolean | false | Sets the dashes marching towards the target. |
dashed | boolean | false | Draws it dashed. |
color | FlowColor | — | One of the theme's roles. Without it the edge is a quiet grey. |
width | number | 2 | How thick the line is, in pixels at a zoom of 1. |
arrow | boolean | false | Draws an arrowhead where it arrives. |
fromSide | FlowSide | — | The face it leaves from. Worked out from where the two nodes are when left out. |
toSide | FlowSide | — | The face it arrives at. |
radius | number | 8 | How round the corners of a smoothstep route are. |
gap | number | 20 | How far a routed edge runs straight out of a node before it turns. |
curvature | number | 0.25 | How far a bezier bulges when its target is behind its source. |
Flow.Controls
| Prop | Type | Default | Description |
|---|---|---|---|
zoom | boolean | true | Shows the two zoom buttons. |
fit | boolean | true | Shows the button that fits every node into view. |
lock | boolean | true | Shows the button that locks the canvas against panning, zooming and dragging. |
step | number | 1.3 | How much one press of a zoom button zooms. |
position | FlowCorner | 'bottom-right' | Which corner of the canvas they sit in. |
Flow.MiniMap
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | 120 | How wide the map is, in pixels. |
height | number | 80 | How tall the map is, in pixels. |
position | FlowCorner | 'bottom-left' | Which corner of the canvas it sits in. |
Every part also takes the props of the element it renders, and a className.
Notes
Size
The canvas is 24rem tall and as wide as its container. Set a height with className.
It is drawn as a bordered, rounded box. Inside a frame that already has an edge, such as a card or a panel, give it the frame's height and take its own edge off: className="h-full rounded-none border-0". The examples on this page do that.
fitViewOnMount fits the graph once. Where the room for the graph changes a great deal, as between a phone and a desktop, lay the nodes out for each and give the canvas a key that changes with the layout, so it is fitted again.
Positions
position is the node's top left corner on the graph, in pixels at a zoom of 1. The canvas holds a node's position after that: a drag moves it, and onNodeDragEnd reports where it stopped. To move a node yourself, keep its position in state and pass the new one.
fitViewOnMount zooms and pans so every node is in view once they have been measured. It never zooms in past 1.
Edges
from and to are node ids, or { node, handle } to join a handle by its id. The faces an edge uses are worked out from where the two nodes are, and change as one is dragged past the other; fromSide and toSide fix them.
variant is bezier, smoothstep, step or straight. animated sets dashes marching towards the target, and stops for anyone who has asked for reduced motion. color names one of the theme's roles; without it an edge is a quiet grey.
Panning and zooming
Drag the empty canvas to pan. Zoom with a pinch, or with Control or Command and the wheel. The plain wheel is left to the page, so a diagram in a long page does not trap its scroll. Flow.Controls has buttons for the same things, and a lock that stops all of it.
Keyboard
The canvas and every node are Tab stops. On the canvas the arrow keys pan, plus and minus zoom and 0 fits everything. On a node the arrow keys move it by moveStep, four times as far with Shift, Enter presses it and Delete calls onDelete.
A connection can only be drawn with a pointer. Where joining nodes matters to everyone, give them another way to do it, such as a menu on the node.
Inside a node
A node is a card by default; pass className to change it. Buttons, links and fields inside a node work as usual and do not start a drag. Put data-no-drag on anything else that should not.
Groups
A group's children are ordinary elements, laid out however you like inside it. They are not nodes: an edge is joined to the group, not to something in it.
Server Components
Flow is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Flow.Background off the import, so each part is also exported by name: FlowBackground is Flow.Background, and so on for the others.
import { Flow, FlowBackground, FlowNode, FlowHandle, FlowEdge, FlowGroup, FlowControls, FlowMiniMap } from "@/components/panelui/flow"