Elements

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>
  );
}