Skip to content
Component

Flow diagram

A diagram of steps and arrows that shows order and branches, with optional lanes.

  • #arc
  • #component
  • #diagram
  • #flow
  • #visualization
  • #process

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/flow-diagram.json

flow-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.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.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 };
}