Flow Chart
NewInstallation
npx shadcn@latest add https://uibrik.com/r/flow-chart.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/flow-chart/flow-chart.tsx"use client"; import { useMemo, useState, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Tooltip, TooltipRow, TooltipTitle, useWidth } from "../_charts/parts"; import { clamp } from "../_charts/scale"; import { accentVars, DRAW_IN, EASE_OUT, seriesColor } from "../_charts/theme"; import { CHAR } from "../_charts/axis"; import { formatCompact, formatShare, formatValue, type ValueFormat } from "../_charts/format"; /** Value moving from one node to another. Nodes are named by their id. */ export type FlowLink = { source: string; target: string; value: number }; const SAMPLE_LINKS: FlowLink[] = [ { source: "Organic", target: "Free", value: 3200 }, { source: "Organic", target: "Pro", value: 1800 }, { source: "Paid", target: "Free", value: 1500 }, { source: "Paid", target: "Pro", value: 1100 }, { source: "Paid", target: "Team", value: 500 }, { source: "Referral", target: "Pro", value: 900 }, { source: "Referral", target: "Team", value: 700 }, { source: "Free", target: "Active", value: 3100 }, { source: "Free", target: "Churned", value: 1600 }, { source: "Pro", target: "Active", value: 3200 }, { source: "Pro", target: "Churned", value: 600 }, { source: "Team", target: "Active", value: 1500 }, { source: "Team", target: "Churned", value: 200 }, ]; const TOP = 8; const BOTTOM = 8; const NODE_W = 8; const GAP = 14; const MIN_LINK = 1.5; // Width of one 12px medium character, the gap to the node, and the gap before a label's number. const LABEL_CHAR = 7.2; const LABEL_GAP = 12; const VALUE_GAP = 6; type Hover = { kind: "node"; id: string } | { kind: "link"; index: number }; export type FlowChartProps = { /** Names what is plotted. */ title?: string; /** Flows between nodes. Columns follow the links, left to right. */ links?: FlowLink[]; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** First colour. Empty uses the chart palette. */ accent?: string; /** Plot height in px. */ height?: number; /** Little dots that travel along the flows you point at. */ particles?: boolean; className?: string; }; export function FlowChart({ title = "Customer flow", links = SAMPLE_LINKS, format = {}, accent = "", height = 320, particles = true, className, }: FlowChartProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const [hover, setHover] = useState<Hover | null>(null); const [pointer, setPointer] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const fmt = (v: number) => formatValue(v, format); // Nodes, columns and node sizes. Depends on the data only. const graph = useMemo(() => { const ids: string[] = []; for (const l of links) for (const id of [l.source, l.target]) if (!ids.includes(id)) ids.push(id); const depth = new Map(ids.map((id) => [id, 0])); for (let pass = 0; pass < ids.length; pass++) { for (const l of links) depth.set(l.target, Math.max(depth.get(l.target) ?? 0, (depth.get(l.source) ?? 0) + 1)); } const cols = Math.max(0, ...depth.values()) + 1; const inSum = new Map(ids.map((id) => [id, 0])); const outSum = new Map(ids.map((id) => [id, 0])); for (const l of links) { outSum.set(l.source, (outSum.get(l.source) ?? 0) + l.value); inSum.set(l.target, (inSum.get(l.target) ?? 0) + l.value); } // Nodes that only receive sit in the last column, so every flow ends flush. for (const id of ids) if (!outSum.get(id)) depth.set(id, cols - 1); const value = (id: string) => Math.max(inSum.get(id) ?? 0, outSum.get(id) ?? 0); const columns = Array.from({ length: cols }, (_, c) => ids.filter((id) => depth.get(id) === c)); const total = columns[0]?.reduce((a, id) => a + value(id), 0) ?? 0; return { ids, depth, cols, columns, value, total, inSum, outSum }; }, [links]); const { ids, depth, cols, columns, value, total } = graph; const colorOf = useMemo(() => { // A node takes the colour of the flow that feeds it most, so a hue can be followed to the end. const map = new Map<string, string>(); columns[0]?.forEach((id, i) => map.set(id, seriesColor(i))); const byDepth = [...ids].sort((a, b) => (depth.get(a) ?? 0) - (depth.get(b) ?? 0)); for (const id of byDepth) { if (map.has(id)) continue; const feed = links.filter((l) => l.target === id).sort((a, b) => b.value - a.value)[0]; map.set(id, feed ? (map.get(feed.source) ?? seriesColor(0)) : seriesColor(0)); } return map; }, [columns, ids, depth, links]); const layout = useMemo(() => { // Labels of the outer columns sit outside the nodes, so the plot makes room for them. // Their numbers are the first thing to go when the chart is too narrow. const pads = (withValues: boolean) => { const text = (id: string) => id.length * LABEL_CHAR + (withValues ? VALUE_GAP + formatCompact(value(id), format).length * CHAR : 0) + LABEL_GAP; return { left: Math.max(0, ...(columns[0] ?? []).map(text)), right: cols > 1 ? Math.max(0, ...(columns[cols - 1] ?? []).map(text)) : 0, }; }; let showValues = true; let { left: leftPad, right: rightPad } = pads(true); if (leftPad + rightPad > width * 0.45) { showValues = false; ({ left: leftPad, right: rightPad } = pads(false)); } const x0 = leftPad; const x1 = Math.max(x0 + NODE_W, width - rightPad - NODE_W); const plot = height - TOP - BOTTOM; // One scale for every column, so a unit of value is the same thickness everywhere. const k = Math.min( ...columns.map((col) => { const sum = col.reduce((a, id) => a + value(id), 0); return sum ? (plot - GAP * (col.length - 1)) / sum : Infinity; }), ); const nodes = new Map<string, { x: number; y: number; h: number; col: number; index: number }>(); columns.forEach((col, c) => { const stack = col.reduce((a, id) => a + value(id) * k, 0) + GAP * (col.length - 1); let y = TOP + (plot - stack) / 2; col.forEach((id, index) => { const h = value(id) * k; nodes.set(id, { x: cols > 1 ? x0 + ((x1 - x0) * c) / (cols - 1) : x0, y, h, col: c, index }); y += h + GAP; }); }); // Each node hands its links out in the order of the nodes they reach, which keeps ribbons from crossing. const rank = (id: string) => nodes.get(id)?.index ?? 0; const thickness = links.map((l) => Math.max(MIN_LINK, l.value * k)); const sy = links.map(() => 0); const ty = links.map(() => 0); const outCursor = new Map<string, number>(); const inCursor = new Map<string, number>(); const order = links.map((_, i) => i); for (const i of [...order].sort((a, b) => rank(links[a].target) - rank(links[b].target))) { const s = nodes.get(links[i].source); if (!s) continue; const used = outCursor.get(links[i].source) ?? 0; sy[i] = s.y + used + thickness[i] / 2; outCursor.set(links[i].source, used + links[i].value * k); } for (const i of [...order].sort((a, b) => rank(links[a].source) - rank(links[b].source))) { const t = nodes.get(links[i].target); if (!t) continue; const used = inCursor.get(links[i].target) ?? 0; ty[i] = t.y + used + thickness[i] / 2; inCursor.set(links[i].target, used + links[i].value * k); } const placed = links.map((l, i) => { const s = nodes.get(l.source); const t = nodes.get(l.target); if (!s || !t) return { d: "", thickness: thickness[i] }; const ax = s.x + NODE_W; const mid = (ax + t.x) / 2; return { d: `M${ax},${sy[i]}C${mid},${sy[i]},${mid},${ty[i]},${t.x},${ty[i]}`, thickness: thickness[i] }; }); return { nodes, placed, showValues }; }, [columns, cols, links, value, format, width, height]); // Everything upstream and downstream of what's under the pointer stays lit. const lit = useMemo(() => { if (!hover) return null; const linkSet = new Set<number>(); const nodeSet = new Set<string>(); const walk = (id: string, dir: "up" | "down") => links.forEach((l, i) => { if ((dir === "down" ? l.source : l.target) !== id || linkSet.has(i)) return; linkSet.add(i); const next = dir === "down" ? l.target : l.source; nodeSet.add(next); walk(next, dir); }); if (hover.kind === "node") { nodeSet.add(hover.id); walk(hover.id, "down"); walk(hover.id, "up"); } else { const l = links[hover.index]; linkSet.add(hover.index); nodeSet.add(l.source); nodeSet.add(l.target); walk(l.target, "down"); walk(l.source, "up"); } return { linkSet, nodeSet }; }, [hover, links]); const track = (e: PointerEvent<HTMLDivElement>) => { const rect = e.currentTarget.getBoundingClientRect(); setPointer({ x: e.clientX - rect.left, y: e.clientY - rect.top }); }; const hoverValue = hover?.kind === "node" ? value(hover.id) : hover ? links[hover.index].value : total; const hoverLabel = hover?.kind === "node" ? hover.id : hover ? `${links[hover.index].source} → ${links[hover.index].target}` : "Total in"; return ( <ChartCard title={title} value={<Headline value={hoverValue} format={format} />} meta={<span className="truncate text-xs text-muted-foreground">{hoverLabel}</span>} className={cn("max-w-3xl", className)} style={accentVars(accent)} > <div ref={ref} className="relative touch-none" style={{ height }} onPointerMove={track} onPointerLeave={() => setHover(null)} > {width > 0 && links.length > 0 && ( <svg width={width} height={height} className="block overflow-visible" role="img" aria-label={`${title}: ${links.length} flows`}> {links.map((l, i) => { const p = layout.placed[i]; const on = lit?.linkSet.has(i); return ( <motion.path key={`${l.source}-${l.target}`} d={p.d} fill="none" stroke={colorOf.get(l.source)} strokeWidth={p.thickness} className="cursor-pointer" initial={{ pathLength: reduce ? 1 : 0, opacity: 0 }} animate={{ pathLength: 1, opacity: lit ? (on ? 0.55 : 0.07) : 0.3 }} transition={{ pathLength: { duration: reduce ? 0 : DRAW_IN, ease: EASE_OUT, delay: reduce ? 0 : 0.05 * (depth.get(l.source) ?? 0) }, opacity: { duration: reduce ? 0 : 0.2 }, }} onPointerEnter={() => setHover({ kind: "link", index: i })} onPointerLeave={() => setHover(null)} /> ); })} {/* Dots drift along the flows in focus, only while you point at them. */} {particles && !reduce && lit && [...lit.linkSet].flatMap((i) => { const p = layout.placed[i]; const count = clamp(Math.round(p.thickness / 4), 1, 3); return Array.from({ length: count }, (_, j) => ( <circle key={`${i}-${j}`} r={Math.min(2.5, Math.max(1.2, p.thickness / 3))} className="pointer-events-none fill-foreground/70"> <animateMotion dur="2.4s" begin={`-${(j * 2.4) / count}s`} repeatCount="indefinite" path={p.d} /> </circle> )); })} {ids.map((id) => { const n = layout.nodes.get(id); if (!n) return null; const on = !lit || lit.nodeSet.has(id); const first = n.col === 0; const last = n.col === cols - 1; return ( <motion.g key={id} initial={{ opacity: 0 }} animate={{ opacity: on ? 1 : 0.3 }} transition={{ duration: reduce ? 0 : 0.25 }} tabIndex={0} role="img" aria-label={`${id}: ${fmt(value(id))}`} className="cursor-pointer outline-none" onPointerEnter={() => setHover({ kind: "node", id })} onPointerLeave={() => setHover(null)} onFocus={() => { setHover({ kind: "node", id }); setPointer({ x: n.x + NODE_W / 2, y: n.y }); }} onBlur={() => setHover(null)} > <rect x={n.x} y={n.y} width={NODE_W} height={Math.max(2, n.h)} rx={3} fill={colorOf.get(id)} /> <text x={first ? n.x - 8 : last ? n.x + NODE_W + 8 : n.x + NODE_W + 8} y={n.y + Math.max(2, n.h) / 2} dy="0.32em" textAnchor={first ? "end" : "start"} className="fill-foreground text-xs font-medium" style={{ paintOrder: "stroke", stroke: "var(--background)", strokeWidth: 3, strokeLinejoin: "round" }} > {id} {(last || first) && layout.showValues && ( <tspan dx={VALUE_GAP} className="fill-muted-foreground tabular-nums"> {formatCompact(value(id), format)} </tspan> )} </text> </motion.g> ); })} </svg> )} <Tooltip x={pointer.x} y={pointer.y - 6} bounds={width} visible={hover !== null}> {hover?.kind === "link" && ( <> <TooltipTitle> {links[hover.index].source} → {links[hover.index].target} </TooltipTitle> <TooltipRow color={colorOf.get(links[hover.index].source) ?? ""} value={fmt(links[hover.index].value)} label={formatShare(total ? links[hover.index].value / total : 0)} /> </> )} {hover?.kind === "node" && ( <> <TooltipTitle>{hover.id}</TooltipTitle> <TooltipRow color={colorOf.get(hover.id) ?? ""} value={fmt(value(hover.id))} label={formatShare(total ? value(hover.id) / total : 0)} /> </> )} </Tooltip> </div> <Announce>{hover ? `${hoverLabel}: ${fmt(hoverValue)}` : ""}</Announce> <DataTable caption={title} columns={["From", "To", "Value"]} rows={links.map((l) => [l.source, l.target, fmt(l.value)])} /> </ChartCard> ); }
Adds the files to
components/uibrik/flow-chart and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/flow-chart.Props
FlowChart- title
- Type string
- "Customer flow"
- Names what is plotted.
- height
- Type number
- 320
- Plot height.
- particles
- Type boolean
- true
- Little dots that travel along the flows you point at.
- accent
- Type string
- ""
- First colour. Empty uses the chart palette.
- links
- Type { source: string; target: string; value: number }[]
- –
- Flows between nodes. Columns follow the links, left to right.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number.
- className
- Type string
- –
- Classes for the outer shell.