Component
Flow diagram
A diagram of steps and arrows that shows order and branches, with optional lanes.
- #arc
- #component
- #diagram
- #flow
- #visualization
- #process
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/flow-diagram.jsonFiles
components/arc/flow-diagram/flow-diagram.module.csscomponents/arc/flow-diagram/flow-diagram.tsxcomponents/arc/flow-diagram/flow-layout.ts
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownflow-diagram.module.cssflow-diagram.module.cssflow-diagram.module.csscss
/* The root is the size container: a horizontal diagram turns vertical when it is narrower than five node widths. */
.root { container-type: inline-size; display: grid; gap: var(--space-3); margin: 0; min-width: 0; }
.bands { display: grid; gap: var(--space-3); min-width: 0; }
.bands[data-lanes] .band { padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface-muted); }
.band { display: grid; gap: var(--space-2); min-width: 0; }
.lane { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); font-weight: 600; }
/* Cells and the arrow gaps between them alternate on one axis: a row when horizontal, a column when vertical or when the container is narrow. */
.grid { display: flex; align-items: stretch; min-width: 0; }
.cell { flex: 1 1 0; display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); min-width: 0; }
.link { flex: 0 0 var(--space-8); display: grid; place-items: center; align-content: center; gap: 2px; min-width: 0; }
.node { display: grid; gap: 2px; padding: var(--space-3) var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); text-align: center; line-height: var(--leading-snug, 1.35); }
.node[data-tone="blue"] { border-color: transparent; background: var(--accent); color: var(--accent-foreground); }
.node[data-tone="yellow"] { border-color: transparent; background: var(--highlight); color: var(--highlight-foreground); }
.node[data-tone="ink"] { border-color: transparent; background: var(--inverse); color: var(--inverse-foreground); }
.name { font-size: var(--text-base, 1rem); font-weight: 700; overflow-wrap: anywhere; }
.note { font-size: var(--text-sm); font-weight: 500; opacity: .9; overflow-wrap: anywhere; }
.cross { margin-top: 2px; font-size: var(--text-sm); font-weight: 500; opacity: .9; overflow-wrap: anywhere; }
.arrow { position: relative; display: block; width: 100%; height: 2px; background: var(--border-strong); }
.arrow::after { content: ""; position: absolute; right: 0; top: 50%; width: 7px; height: 7px; border-top: 2px solid var(--border-strong); border-right: 2px solid var(--border-strong); transform: translateY(-50%) rotate(45deg); transform-origin: 70% 30%; }
.edgeLabel { color: var(--text-secondary); font-size: var(--text-sm); font-weight: 600; text-align: center; overflow-wrap: anywhere; }
.link[data-empty] .arrow { display: none; }
/* Vertical, set or forced by a narrow container: the cells and arrows stack, and the arrow points down. */
.root[data-direction="vertical"] .grid { flex-direction: column; }
.root[data-direction="vertical"] .cell { flex: none; }
.root[data-direction="vertical"] .link { flex: 0 0 var(--space-6); width: auto; }
.root[data-direction="vertical"] .arrow { width: 2px; height: 100%; min-height: var(--space-5); }
.root[data-direction="vertical"] .arrow::after { right: auto; left: 50%; top: auto; bottom: 0; transform: translateX(-50%) rotate(135deg); transform-origin: 30% 70%; }
@container (max-width: 520px) {
.root[data-direction="horizontal"] .grid { flex-direction: column; }
.root[data-direction="horizontal"] .cell { flex: none; }
.root[data-direction="horizontal"] .link { flex: 0 0 var(--space-6); }
.root[data-direction="horizontal"] .arrow { width: 2px; height: 100%; min-height: var(--space-5); }
.root[data-direction="horizontal"] .arrow::after { right: auto; left: 50%; top: auto; bottom: 0; transform: translateX(-50%) rotate(135deg); transform-origin: 30% 70%; }
}
.caption { color: var(--text-secondary); font-size: var(--text-sm); text-align: center; }
.sr { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; list-style: none; }
/* The reveal: layers rise in one after another, a step apart. */
.cell, .link { transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-standard) var(--ease-standard); transition-delay: calc(var(--step, 0) * 90ms); }
.root[data-reveal="pending"] .cell, .root[data-reveal="pending"] .link { opacity: 0; transform: translateY(8px); }
@media (prefers-reduced-motion: reduce) { .cell, .link { transition: none; } .root[data-reveal="pending"] .cell, .root[data-reveal="pending"] .link { opacity: 1; transform: none; } }
:root[data-motion="reduce"] .cell, :root[data-motion="reduce"] .link { transition: none; }
@media print { .root[data-reveal="pending"] .cell, .root[data-reveal="pending"] .link { opacity: 1; transform: none; } }
flow-diagram.tsxflow-diagram.tsxflow-diagram.tsxtsx
"use client";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useInView } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { layoutFlow, type FlowEdge, type FlowNode, type FlowTone } from "./flow-layout";
import styles from "./flow-diagram.module.css";
export type { FlowEdge, FlowNode, FlowTone };
/** Use a flow diagram to show how steps, systems or decisions lead to one another when order matters more than exact positions. */
export interface FlowDiagramProps {
nodes: FlowNode[];
/** Arrows between node ids. Leave it out and the nodes form one chain in the order given. */
edges?: FlowEdge[];
/** Names the diagram for assistive technology. */
label: string;
/** `horizontal` flows left to right; on a narrow container it turns vertical by itself. */
direction?: "horizontal" | "vertical";
/** A line under the diagram, such as its source. */
caption?: string;
className?: string;
}
/**
* A diagram of nodes and arrows. Nodes are put in layers by their arrows and the layers run left to right (or top to bottom); nodes with a `lane`
* sit in a labeled band per lane, and arrows to another lane are written under the node as "→ name". The text is all in the markup, in order, with the
* arrows also written out as sentences for screen readers. When it scrolls into view the layers appear one after another; with reduced motion
* (or no script) everything is simply there.
*/
export function FlowDiagram({ nodes, edges, label, direction = "horizontal", caption, className }: FlowDiagramProps) {
const layout = useMemo(() => layoutFlow(nodes, edges), [nodes, edges]);
const reduce = useReducedMotion();
const ref = useRef<HTMLElement | null>(null);
const inView = useInView(ref, { once: true, amount: 0.25 });
const [armed, setArmed] = useState(false);
useEffect(() => {
// Only a visitor who can see the motion gets the hidden start; anyone else (and the server markup) has every node shown.
setArmed(!reduce); // eslint-disable-line react-hooks/set-state-in-effect
}, [reduce]);
const reveal = armed && !inView ? "pending" : "shown";
if (nodes.length === 0) return null;
const hasLanes = layout.bands.some((b) => b.lane);
return (
<figure ref={ref} className={[styles.root, className].filter(Boolean).join(" ")} data-direction={direction} data-reveal={reveal} aria-label={label} role="group">
<div className={styles.bands} data-lanes={hasLanes ? "" : undefined}>
{layout.bands.map((band, b) => (
<div key={band.lane ?? `band-${b}`} className={styles.band}>
{band.lane ? <p className={styles.lane}>{band.lane}</p> : null}
<div className={styles.grid} style={{ "--layers": layout.layers } as CSSProperties}>
{Array.from({ length: layout.layers }, (_, layer) => {
const here = band.placed.filter((p) => p.layer === layer);
const gap = band.gaps[layer] ?? null;
return (
<Fragment key={layer}>
<div className={styles.cell} style={{ "--slot": 2 * layer + 1, "--step": layer } as CSSProperties}>
{here.map(({ node, crossTo }) => (
<div key={node.id} className={styles.node} data-tone={node.tone ?? "plain"}>
<span className={styles.name}>{node.label}</span>
{node.note ? <span className={styles.note}>{node.note}</span> : null}
{crossTo.length > 0 ? (
<span className={styles.cross}>
{crossTo.map((c) => `→ ${c.label}${c.lane ? ` (${c.lane})` : ""}`).join(" ")}
</span>
) : null}
</div>
))}
</div>
{layer < layout.layers - 1 ? (
<div className={styles.link} style={{ "--slot": 2 * layer + 2, "--step": layer } as CSSProperties} data-empty={gap ? undefined : ""} aria-hidden="true">
{gap ? (
<>
<span className={styles.arrow} />
{gap.labels.length > 0 ? <span className={styles.edgeLabel}>{gap.labels.join(" · ")}</span> : null}
</>
) : null}
</div>
) : null}
</Fragment>
);
})}
</div>
</div>
))}
</div>
<ul className={styles.sr}>
{layout.sentences.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
{caption ? <figcaption className={styles.caption}>{caption}</figcaption> : null}
</figure>
);
}
export default FlowDiagram;
flow-layout.tsflow-layout.tsflow-layout.tsts
export type FlowTone = "plain" | "blue" | "yellow" | "ink";
export interface FlowNode {
id: string;
label: string;
/** A short second line under the label. */
note?: string;
tone?: FlowTone;
/** Nodes with the same lane sit in one band with the lane's name. */
lane?: string;
}
export interface FlowEdge {
from: string;
to: string;
label?: string;
}
export interface FlowPlaced {
node: FlowNode;
layer: number;
/** Where the node's other-lane arrows go, as "label (lane)" text. */
crossTo: { label: string; lane: string; edgeLabel?: string }[];
}
export interface FlowBand {
lane?: string;
placed: FlowPlaced[];
/** For each gap between layer i and i + 1, the labels of the arrows that cross it inside this band, or `null` when none cross. */
gaps: ({ labels: string[] } | null)[];
}
export interface FlowLayout {
layers: number;
bands: FlowBand[];
/** The same edges as plain sentences, for assistive technology. */
sentences: string[];
}
/**
* Puts the nodes into layers (a node sits one layer after its latest predecessor, so arrows always point forward) and the layers into bands (one band
* per lane, or one band for the whole diagram). Edges to unknown nodes are dropped and a cycle cannot loop: a node's layer stops growing after
* `nodes.length` passes. With no edges at all the nodes form one chain in the order given.
*/
export function layoutFlow(nodes: readonly FlowNode[], edges: readonly FlowEdge[] | undefined): FlowLayout {
const byId = new Map(nodes.map((n) => [n.id, n]));
const known = (edges ?? []).filter((e) => byId.has(e.from) && byId.has(e.to) && e.from !== e.to);
const used: FlowEdge[] = known.length > 0 ? known : nodes.slice(1).map((n, i) => ({ from: nodes[i]!.id, to: n.id }));
const layerOf = new Map(nodes.map((n) => [n.id, 0]));
for (let pass = 0; pass < nodes.length; pass += 1) {
let moved = false;
for (const e of used) {
const next = layerOf.get(e.from)! + 1;
if (next > layerOf.get(e.to)! && next < nodes.length) {
layerOf.set(e.to, next);
moved = true;
}
}
if (!moved) break;
}
const layers = nodes.length === 0 ? 0 : Math.max(...layerOf.values()) + 1;
const lanes: (string | undefined)[] = nodes.some((n) => n.lane) ? [...new Set(nodes.map((n) => n.lane ?? ""))].map((l) => (l === "" ? undefined : l)) : [undefined];
const laneOf = (id: string) => byId.get(id)!.lane || undefined;
const bands: FlowBand[] = lanes.map((lane) => {
const placed: FlowPlaced[] = nodes
.filter((n) => (n.lane || undefined) === lane)
.map((node) => ({
node,
layer: layerOf.get(node.id)!,
crossTo: used
.filter((e) => e.from === node.id && laneOf(e.to) !== lane)
.map((e) => ({ label: byId.get(e.to)!.label, lane: laneOf(e.to) ?? "", edgeLabel: e.label })),
}));
const gaps: FlowBand["gaps"] = [];
for (let i = 0; i + 1 < layers; i += 1) {
const crossing = used.filter((e) => laneOf(e.from) === lane && laneOf(e.to) === lane && layerOf.get(e.from)! <= i && layerOf.get(e.to)! > i);
gaps.push(crossing.length === 0 ? null : { labels: crossing.map((e) => e.label ?? "").filter(Boolean) });
}
return { lane, placed, gaps };
});
const sentences = used.map((e) => `${byId.get(e.from)!.label} → ${byId.get(e.to)!.label}${e.label ? ` (${e.label})` : ""}`);
return { layers, bands, sentences };
}