Dependency Graph
SaveWhich steps wait on other steps. Selecting a step highlights its upstream and downstream dependencies; focus on a branch to show only its ancestors and descendants.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Dependency Graph preview
Selected step: api
Invoice feature
12 steps1running1needs approval1failed3succeeded5queued1skipped
Installation
npx @seamlessui/cli add dependency-graphPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add dependency-graph --dry-runUsage
dependency-graph-example.tsx
import { DependencyGraph, type DependencyEdge, type DependencyNode } from "@/components/seamless/graph/dependency-graph";
const nodes: DependencyNode[] = [
{ id: "schema", label: "Design the schema", kind: "design", status: "succeeded" },
{ id: "migration", label: "Write the migration", kind: "code", status: "running" },
{ id: "api", label: "Update the API", kind: "code", status: "failed" },
{ id: "docs", label: "Update the docs", kind: "docs", status: "queued" },
{ id: "tests", label: "Write tests", kind: "test", status: "queued" },
{ id: "deploy", label: "Deploy", kind: "release", status: "queued" },
];
// target waits on source.
const edges: DependencyEdge[] = [
{ id: "e1", source: "schema", target: "migration", type: "requires" },
{ id: "e2", source: "schema", target: "api", type: "requires" },
{ id: "e3", source: "api", target: "docs", type: "optional" },
{ id: "e4", source: "migration", target: "tests", type: "requires" },
{ id: "e5", source: "api", target: "tests", type: "requires" },
{ id: "e6", source: "tests", target: "deploy", type: "requires" },
];
export default function DependencyGraphExample() {
return <DependencyGraph title="Invoice feature" nodes={nodes} edges={edges} defaultSelectedId="api" height={360} />;
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | { id, label, kind?, status, summary? }[] | — | Steps with stable ids. |
| edges | { id, source, target, type: "requires" | "optional", label? }[] | — | target waits on source. Optional edges are dashed and never block. |
| selectedId / onSelect | string | null / (id: string | null) => void | — | Controlled selection. Leave selectedId undefined for uncontrolled use. |
| focusedId / onFocusChange | string | null / (id: string | null) => void | — | Controlled branch focus. null shows the full graph. Focusing hides other nodes without moving any. |
| defaultSelectedId / defaultFocusedId | string | null | null | Initial values when uncontrolled. |
| defaultView | "graph" | "list" | "graph" | Which view shows first. |
| height | number | 440 | Height of the graph and list in pixels. |
| waitingOn(nodes, edges) | => Map<string, string[]> | — | Unfinished required prerequisites per step, as shown in the list and details. |
| direction | "TB" | "LR" | "TB" | Layout direction. Top to bottom suits deep, narrow runs in a side-by-side layout; left to right suits wide graphs such as the 100-node fixture. |
Accessibility
- Upstream and downstream steps get a text tag (Upstream, Downstream) on the node and in the list; unrelated steps and edges are dimmed.
- The list view shows each step's status, kind, dependencies, and what it is waiting on, and supports arrow keys, Home, and End.
- Focus on branch and Show full graph are buttons in the header, reachable by Tab; the header text states which branch is shown.
- Requires and optional edges differ by solid and dashed lines, explained in a text legend.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/graph/dependency-graph.tsxdependency-graph- Also installs graph-canvas@0.2.0registry dependency
- Also installs agent-run@0.1.1registry dependency
- Also installs step-list@0.2.0registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/dependency-graph/0.2.0.json.
graph/dependency-graph.tsxShowHide
graph/dependency-graph.tsx
"use client";
import { type ComponentProps, useMemo, useState } from "react";
import { GraphCanvas, type GraphCanvasEdge, type GraphCanvasNode, useGraphLayout } from "./graph-canvas";
import { adjacency, ancestorsOf, descendantsOf, GRAPH_STATUS_LABELS, type GraphDirection, type GraphStatus, layoutEdges } from "./graph-layout";
import {
DetailHeader,
DetailNote,
DetailProperties,
DetailSection,
DetailStack,
EdgeLegend,
EmptyDetails,
graphButtonClass,
GraphListbox,
GraphListRow,
GraphShell,
type GraphView,
GraphViewSwitch,
nameList,
RelatedButton,
StatusCounts,
useControllableState,
} from "./graph-ui";
/** requires: the target cannot start until the source is done. optional: the target uses the source's output if it is ready. */
export type DependencyEdgeType = "requires" | "optional";
export interface DependencyNode {
/** Stable id. Keep it the same across updates so the node keeps its place. */
id: string;
label: string;
/** Free-form kind shown after the status, for example "task" or "build". */
kind?: string;
status: GraphStatus;
summary?: string;
}
/** `target` waits on `source`. */
export interface DependencyEdge {
id: string;
source: string;
target: string;
type: DependencyEdgeType;
label?: string;
}
export interface DependencyGraphProps extends Omit<ComponentProps<"section">, "children" | "onSelect" | "title"> {
nodes: readonly DependencyNode[];
edges: readonly DependencyEdge[];
title?: string;
/** Controlled selection. Pass `null` for none; leave undefined to let the component manage it. */
selectedId?: string | null;
defaultSelectedId?: string | null;
onSelect?: (id: string | null) => void;
/** Controlled branch focus: the node whose ancestors and descendants are shown alone. `null` shows the full graph. */
focusedId?: string | null;
defaultFocusedId?: string | null;
onFocusChange?: (id: string | null) => void;
defaultView?: GraphView;
height?: number;
direction?: GraphDirection;
}
const done = new Set<GraphStatus>(["succeeded", "skipped"]);
/** For each node, the required prerequisites that have not finished: what it is waiting on. */
export function waitingOn(nodes: readonly DependencyNode[], edges: readonly DependencyEdge[]): Map<string, string[]> {
const status = new Map(nodes.map((node) => [node.id, node.status]));
const result = new Map<string, string[]>();
for (const edge of edges) {
if (edge.type !== "requires" || done.has(status.get(edge.source) ?? "queued")) continue;
(result.get(edge.target) ?? result.set(edge.target, []).get(edge.target)!).push(edge.source);
}
return result;
}
function StepCount({ count }: { count: number }) {
return (
<>
<span className="font-mono tabular-nums">{count}</span> {count === 1 ? "step" : "steps"}
</>
);
}
/** Which steps wait on which. Selecting a step highlights what it depends on and what depends on it; focus narrows the graph to that branch. */
export function DependencyGraph({
nodes,
edges,
title = "Dependencies",
selectedId,
defaultSelectedId = null,
onSelect,
focusedId,
defaultFocusedId = null,
onFocusChange,
defaultView = "graph",
height = 440,
direction = "TB",
...props
}: DependencyGraphProps) {
const [selected, setSelected] = useControllableState<string | null>(selectedId, defaultSelectedId, onSelect);
const [focused, setFocused] = useControllableState<string | null>(focusedId, defaultFocusedId, onFocusChange);
const [view, setView] = useState<GraphView>(defaultView);
const shownEdges = useMemo(() => layoutEdges(nodes, edges), [nodes, edges]);
// Laid out once for the full graph: focusing hides nodes instead of moving them.
const layout = useGraphLayout(nodes, shownEdges, { direction });
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const links = useMemo(() => adjacency(shownEdges), [shownEdges]);
const waiting = useMemo(() => waitingOn(nodes, shownEdges), [nodes, shownEdges]);
const upstream = useMemo(() => (selected ? ancestorsOf(selected, shownEdges) : new Set<string>()), [selected, shownEdges]);
const downstream = useMemo(() => (selected ? descendantsOf(selected, shownEdges) : new Set<string>()), [selected, shownEdges]);
const visible = useMemo(() => {
if (!focused || !byId.has(focused)) return null;
return new Set([focused, ...ancestorsOf(focused, shownEdges), ...descendantsOf(focused, shownEdges)]);
}, [focused, byId, shownEdges]);
const shownNodes = useMemo(() => (visible ? nodes.filter((node) => visible.has(node.id)) : nodes), [nodes, visible]);
const current = selected ? byId.get(selected) : undefined;
const label = (id: string) => byId.get(id)?.label ?? id;
const canvasNodes = useMemo<GraphCanvasNode[]>(
() =>
nodes.map((node) => {
const relation = !selected || node.id === selected ? null : upstream.has(node.id) ? "upstream" : downstream.has(node.id) ? "downstream" : "muted";
return {
id: node.id,
title: node.label,
subtitle: node.kind,
status: node.status,
tone: relation ?? "default",
tag: relation === "upstream" ? "Upstream" : relation === "downstream" ? "Downstream" : undefined,
hidden: visible ? !visible.has(node.id) : undefined,
};
}),
[nodes, selected, upstream, downstream, visible],
);
const canvasEdges = useMemo<GraphCanvasEdge[]>(
() =>
shownEdges.map((edge) => {
const onPath =
selected != null &&
((upstream.has(edge.source) && (edge.target === selected || upstream.has(edge.target))) ||
(downstream.has(edge.target) && (edge.source === selected || downstream.has(edge.source))));
return {
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
label: edge.label,
dashed: edge.type === "optional",
tone: !selected ? "default" : onPath ? "highlight" : "muted",
hidden: visible ? !(visible.has(edge.source) && visible.has(edge.target)) : undefined,
};
}),
[shownEdges, selected, upstream, downstream, visible],
);
const select = (id: string) => setSelected(id);
return (
<GraphShell
aria-label={title}
title={title}
meta={
visible && focused ? (
<span>
Branch of <span className="font-medium text-zinc-700 dark:text-zinc-300">{label(focused)}</span>:{" "}
<span className="font-mono tabular-nums">{visible.size}</span> of <span className="font-mono tabular-nums">{nodes.length}</span> steps
</span>
) : (
<StatusCounts lead={`${nodes.length} ${nodes.length === 1 ? "step" : "steps"}`} statuses={nodes.map((node) => node.status)} />
)
}
controls={
<>
{visible ? (
<button type="button" className={graphButtonClass} onClick={() => setFocused(null)}>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<path d="M9.5 2.75h3.75V6.5M6.5 13.25H2.75V9.5M13.25 2.75 9 7M2.75 13.25 7 9" />
</svg>
Show full graph
</button>
) : (
<button type="button" className={graphButtonClass} disabled={!current} onClick={() => current && setFocused(current.id)}>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.75v2.5M8 11.75v2.5M1.75 8h2.5M11.75 8h2.5" />
</svg>
Focus on branch
</button>
)}
<GraphViewSwitch view={view} onChange={setView} />
</>
}
detailsLabel="Step dependencies"
height={height}
footer={
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<EdgeLegend items={[{ label: "Requires" }, { label: "Optional", dashed: true }]} />
{selected ? <p className="text-xs text-zinc-500">Upstream: it waits on them. Downstream: they wait on it.</p> : null}
</div>
}
details={
current ? (
<DetailStack>
<DetailHeader title={current.label} status={current.status} meta={[current.kind]} />
{current.summary ? <p className="text-sm text-pretty text-zinc-600 dark:text-zinc-400">{current.summary}</p> : null}
<DetailProperties
items={[
{ label: "Upstream", value: <StepCount count={upstream.size} /> },
{ label: "Downstream", value: <StepCount count={downstream.size} /> },
{
label: "Blocked",
value: waiting.get(current.id)?.length ? (
<span className="text-amber-700 dark:text-amber-400">
Yes, on <StepCount count={waiting.get(current.id)!.length} />
</span>
) : (
"No"
),
},
]}
/>
<div className="space-y-3.5">
<DetailSection label="Waiting on">
{waiting.get(current.id)?.length ? (
waiting.get(current.id)!.map((id) => <RelatedButton key={id} label={label(id)} status={byId.get(id)?.status} onClick={() => select(id)} />)
) : (
<DetailNote>Nothing. Every required step is done.</DetailNote>
)}
</DetailSection>
<DetailSection label="Depends on">
{links.incoming.get(current.id)?.length ? (
links.incoming.get(current.id)!.map((edge) => (
<RelatedButton key={edge.id} label={label(edge.source)} status={byId.get(edge.source)?.status} note={edge.type === "requires" ? "Required" : "Optional"} onClick={() => select(edge.source)} />
))
) : (
<DetailNote>No dependencies.</DetailNote>
)}
</DetailSection>
<DetailSection label="Needed by">
{links.outgoing.get(current.id)?.length ? (
links.outgoing.get(current.id)!.map((edge) => (
<RelatedButton key={edge.id} label={label(edge.target)} status={byId.get(edge.target)?.status} note={edge.type === "requires" ? "Required" : "Optional"} onClick={() => select(edge.target)} />
))
) : (
<DetailNote>Nothing waits on this step.</DetailNote>
)}
</DetailSection>
</div>
</DetailStack>
) : (
<EmptyDetails hint="Then use Focus on branch to hide every unrelated step.">Select a step to highlight what it waits on and what waits on it.</EmptyDetails>
)
}
{...props}
>
{view === "graph" ? (
<GraphCanvas
label={`${title}: ${shownNodes.length} steps. Use the list view to move through steps with the keyboard.`}
nodes={canvasNodes}
edges={canvasEdges}
layout={layout}
selectedNodeId={selected}
onNodeClick={select}
onPaneClick={() => setSelected(null)}
height={height}
/>
) : (
<div style={{ height }} className="overflow-y-auto">
<GraphListbox
label={`${title} steps`}
items={shownNodes}
getId={(node) => node.id}
selectedId={selected}
onSelect={select}
empty="No steps yet."
renderItem={(node) => {
const relation = !selected || node.id === selected ? null : upstream.has(node.id) ? "Upstream" : downstream.has(node.id) ? "Downstream" : null;
const waits = waiting.get(node.id) ?? [];
const needs = links.incoming.get(node.id) ?? [];
return (
<GraphListRow
status={node.status}
title={node.label}
muted={node.status === "queued" || node.status === "skipped" || node.status === "cancelled"}
struck={node.status === "skipped"}
trailing={
relation ? (
<span className="inline-block rounded-[5px] border border-zinc-950/15 px-1.5 font-sans text-[10px] leading-4 font-medium text-zinc-700 dark:border-white/20 dark:text-zinc-300">
{relation}
</span>
) : undefined
}
meta={
<>
{GRAPH_STATUS_LABELS[node.status]}
{node.kind ? ` · ${node.kind}` : ""}
{needs.length ? ` · depends on ${nameList(needs.map((edge) => label(edge.source) + (edge.type === "optional" ? " (optional)" : "")))}` : ""}
</>
}
>
{waits.length ? <p className="mt-0.5 truncate text-xs text-amber-700 dark:text-amber-400">Waiting on {nameList(waits.map(label))}</p> : null}
</GraphListRow>
);
}}
/>
</div>
)}
</GraphShell>
);
}