51 / 122 · Structured output
Flow graph
Work as a graph rather than a list: branches that fan out and rejoin.
intake
Installation
npx shadcn@latest add "@assistant-ui/elements-flow-graph"
Usage
import { FlowGraph } from "@/components/elements/flow-graph";
<FlowGraph nodes={nodes} edges={edges} visibleCount={nodes.length} />Props
FlowGraph
nodes*FlowNode[]Every node with its grid position. Layout is explicit; the element runs no solver.
edges*FlowEdge[]Connections by node id. An edge dims until both of its ends are visible.
visibleCount*numberHow many nodes have been reached so far.
classNamestringExtra classes merged onto the root.
FlowNode
id*stringStable identity for the row key, and how edges name their endpoints. FlowGraph exposes no callbacks, so nothing else reports it.
label*stringText inside the node, rendered in mono and not wrapped.
column*numberZero-based column. Drives horizontal position and the edge curves.
row*numberZero-based row.
state*"done" | "active" | "pending"Done is filled and dim, active is tinted, pending is a dashed outline.
Source
flow-graph.tsx"use client";
import { cn } from "@/lib/utils";
import { mono, paper } from "./surfaces";
export type FlowNodeState = "done" | "active" | "pending";
export interface FlowNode {
id: string;
label: string;
column: number;
row: number;
state: FlowNodeState;
}
export interface FlowEdge {
from: string;
to: string;
}
const COL_W = 96;
const ROW_H = 58;
const NODE_W = 78;
const NODE_H = 30;
export function FlowGraph({
nodes,
edges,
visibleCount,
className,
}: {
nodes: readonly FlowNode[];
edges: readonly FlowEdge[];
visibleCount: number;
className?: string;
}) {
const shown = nodes.slice(0, visibleCount);
const shownIds = new Set(shown.map((node) => node.id));
const columns = Math.max(...nodes.map((node) => node.column)) + 1;
const rows = Math.max(...nodes.map((node) => node.row)) + 1;
const width = (columns - 1) * COL_W + NODE_W;
const height = (rows - 1) * ROW_H + NODE_H;
const center = (node: FlowNode) => ({
x: node.column * COL_W + NODE_W / 2,
y: node.row * ROW_H + NODE_H / 2,
});
return (
<div
className={cn(
paper,
"w-full max-w-md overflow-x-auto rounded-2xl p-4",
className,
)}
>
<div className="relative" style={{ width, height, minWidth: width }}>
<svg
aria-hidden
className="absolute inset-0 overflow-visible"
width={width}
height={height}
>
{edges.map((edge) => {
const from = nodes.find((node) => node.id === edge.from);
const to = nodes.find((node) => node.id === edge.to);
if (!from || !to) return null;
const live = shownIds.has(edge.from) && shownIds.has(edge.to);
const a = center(from);
const b = center(to);
const midX = (a.x + b.x) / 2;
return (
<path
key={`${edge.from}-${edge.to}`}
d={`M ${a.x + NODE_W / 2} ${a.y} C ${midX} ${a.y}, ${midX} ${b.y}, ${b.x - NODE_W / 2} ${b.y}`}
fill="none"
strokeWidth="1.5"
className={cn(
"transition-opacity duration-500 motion-reduce:transition-none",
live ? "stroke-foreground/20" : "stroke-foreground/5",
)}
/>
);
})}
</svg>
{shown.map((node) => (
<div
key={node.id}
className={cn(
"fade-in zoom-in-95 animate-in fill-mode-both absolute flex items-center justify-center rounded-xl border text-center text-[11.5px] leading-tight duration-300",
node.state === "done" &&
"border-foreground/10 bg-foreground/[0.04] text-foreground/50",
node.state === "active" &&
"text-foreground/90 border-blue-500/30 bg-blue-500/10 dark:border-blue-400/30",
node.state === "pending" &&
"border-foreground/8 text-foreground/35 border-dashed",
)}
style={{
left: node.column * COL_W,
top: node.row * ROW_H,
width: NODE_W,
height: NODE_H,
}}
>
<span className={cn(mono, "px-2")}>{node.label}</span>
</div>
))}
</div>
</div>
);
}