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

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import 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-variants

Copy 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. id is what edges call it, position is 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 an id to 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

PropTypeDefaultDescription
defaultViewportFlowViewport—Where the canvas looks to begin with.
minZoomnumber0.3The furthest out it can be zoomed.
maxZoomnumber2.5The furthest in it can be zoomed.
panOnDragbooleantrueWhether dragging the pane pans it.
zoomOnPinchbooleantrueWhether a pinch, or Control with the wheel, zooms it.
fitViewOnMountbooleanfalseFits every node into view once they have been measured.
fitViewPaddingnumber48The 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.
labelstring'Diagram'What a screen reader calls the canvas.

Flow.Background

PropTypeDefaultDescription
variant'dots' | 'lines' | 'cross' | 'none''bezier'Dots, a grid of lines, small crosses, or nothing.
gapnumber20The distance from one mark to the next, in pixels at a zoom of 1.
sizenumber—How big a mark is: a dot's radius, a line's thickness, half a cross's arm.
classNamestring—

Flow.Node

PropTypeDefaultDescription
idstring—What edges and onNodeDragEnd call this node.
positionFlowNodePosition—Where its top left corner is, on the graph.
draggablebooleantrueWhether it can be dragged and moved with the arrow keys.
selectedbooleanfalseDraws 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.
moveStepnumber—How far one arrow key press moves it, in pixels. Shift moves four times as far.

Flow.Group

PropTypeDefaultDescription
labelReactNode—Written in the corner of the group.

Flow.Handle

PropTypeDefaultDescription
idstring—A name for this handle, so an edge can be joined to it and not only to the node.
positionFlowSide'bottom-right'Which face of the node it sits on.
offsetnumber—How far along that face, from 0 to 1.

Flow.Edge

PropTypeDefaultDescription
fromFlowEndpointReference—Where it leaves from: a node's id, or { node, handle }.
toFlowEndpointReference—Where it arrives.
variantFlowEdgeVariant'bezier'A curve, a rounded right-angled route, a square one, or a straight line.
animatedbooleanfalseSets the dashes marching towards the target.
dashedbooleanfalseDraws it dashed.
colorFlowColor—One of the theme's roles. Without it the edge is a quiet grey.
widthnumber2How thick the line is, in pixels at a zoom of 1.
arrowbooleanfalseDraws an arrowhead where it arrives.
fromSideFlowSide—The face it leaves from. Worked out from where the two nodes are when left out.
toSideFlowSide—The face it arrives at.
radiusnumber8How round the corners of a smoothstep route are.
gapnumber20How far a routed edge runs straight out of a node before it turns.
curvaturenumber0.25How far a bezier bulges when its target is behind its source.

Flow.Controls

PropTypeDefaultDescription
zoombooleantrueShows the two zoom buttons.
fitbooleantrueShows the button that fits every node into view.
lockbooleantrueShows the button that locks the canvas against panning, zooming and dragging.
stepnumber1.3How much one press of a zoom button zooms.
positionFlowCorner'bottom-right'Which corner of the canvas they sit in.

Flow.MiniMap

PropTypeDefaultDescription
widthnumber120How wide the map is, in pixels.
heightnumber80How tall the map is, in pixels.
positionFlowCorner'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"