Skip to content
Seamless UI

Dependency Graph

Save

Which 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
Data

Selected step: api

Invoice feature

12 steps1running1needs approval1failed3succeeded5queued1skipped
  • Requires
  • Optional

Upstream: it waits on them. Downstream: they wait on it.

Installation

npx @seamlessui/cli add dependency-graph

Pre-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-run

Usage

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

PropTypeDefaultDescription
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 / onSelectstring | null / (id: string | null) => void—Controlled selection. Leave selectedId undefined for uncontrolled use.
focusedId / onFocusChangestring | null / (id: string | null) => void—Controlled branch focus. null shows the full graph. Focusing hides other nodes without moving any.
defaultSelectedId / defaultFocusedIdstring | nullnullInitial values when uncontrolled.
defaultView"graph" | "list""graph"Which view shows first.
heightnumber440Height 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.tsxShow
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>
  );
}