nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsTrace waterfall
Copy promptCopy prompt

Customize

Run identifier in the header

Loop the scripted demo; it stops on interaction and pauses offscreen

JSX · copied with Copy prompt

<TraceWaterfall autoPlay />

Trace waterfallNew

Live span waterfall for an agent run with LLM, tool, retrieval and sub-agent bars on one timeline, a moving playhead, error spans and a per-span detail panel.

Installation

Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.

$pnpm dlx shadcn@latest add @nextui/trace-waterfall
1{
2 "registries": {
3 "@nextui": {
4 "url": "https://nextui.dev/r/{name}.json",
5 "headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }
6 }
7 }
8}

Usage

1"use client";
2
3import { TraceWaterfall, type TraceSpan } from "@/components/nextui/trace-waterfall";
4
5export function RunTrace({ spans, elapsed }: { spans: TraceSpan[]; elapsed?: number }) {
6 return <TraceWaterfall traceId="run_7f3a" spans={spans} now={elapsed} onSelect={(s) => console.log(s.name)} />;
7}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
traceIdstring"run_7f3a"Run identifier in the header
spansTraceSpan[]–Spans with id, name, kind (agent, llm, tool, retrieval), start, duration, depth, error, tokens, input and output
nownumber–Elapsed ms of a live trace; spans grow and a playhead moves. Leave unset for a finished trace
defaultSelectedstring–Span shown in the detail panel at first
onSelect(span: TraceSpan) => void–Called when a span is picked
autoPlaybooleanfalseLoop the scripted demo; it stops on interaction and pauses offscreen
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/trace-waterfall.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Activity, AlertTriangle, Bot, Database, Sparkles, Wrench } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type TraceSpanKind = "agent" | "llm" | "tool" | "retrieval";

export type TraceSpan = {
  id: string;
  name: string;
  kind: TraceSpanKind;
  /** Start offset from the beginning of the trace, in ms. */
  start: number;
  /** Duration in ms. */
  duration: number;
  depth?: number;
  error?: string;
  tokens?: number;
  /** Short input/output preview shown in the detail panel. */
  input?: string;
  output?: string;
};

export type TraceWaterfallProps = {
  traceId?: string;
  spans?: TraceSpan[];
  /** Elapsed time of a live trace in ms. Leave unset for a finished trace. */
  now?: number;
  defaultSelected?: string;
  onSelect?: (span: TraceSpan) => void;
  /** Loop a scripted live run, then inspect the failed span. */
  autoPlay?: boolean;
  className?: string;
};

const DEFAULT_SPANS: TraceSpan[] = [
  { id: "s1", name: "agent.run", kind: "agent", start: 0, duration: 3100, depth: 0, tokens: 4870, input: "“Why was Acme billed twice in October?”", output: "Answer streamed · 2 citations" },
  { id: "s2", name: "plan", kind: "llm", start: 40, duration: 560, depth: 1, tokens: 1240, input: "system + 3 tools · 1.1k tok", output: "3 steps: search → fetch invoice → summarize" },
  { id: "s3", name: "search_docs", kind: "retrieval", start: 640, duration: 470, depth: 1, input: "q=“duplicate charge october”", output: "6 chunks · top score 0.82" },
  { id: "s4", name: "billing-agent", kind: "agent", start: 1160, duration: 760, depth: 1, tokens: 410, input: "handoff · invoice lookup", output: "INV-2084 · 2 charges found" },
  { id: "s5", name: "fetch_invoice", kind: "tool", start: 1200, duration: 300, depth: 2, error: "504 Gateway Timeout", input: "GET /invoices/INV-2084", output: "504 after 300ms · retrying" },
  { id: "s6", name: "fetch_invoice", kind: "tool", start: 1540, duration: 340, depth: 2, input: "GET /invoices/INV-2084 (retry 1)", output: "200 · 2 line items" },
  { id: "s7", name: "summarize", kind: "llm", start: 1960, duration: 1110, depth: 1, tokens: 3220, input: "6 chunks + invoice · 2.9k tok", output: "312 tok · 288 tok/s" },
];

const KIND = {
  agent: { icon: Bot, bar: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", label: "Agent" },
  llm: { icon: Sparkles, bar: "bg-violet-500", text: "text-violet-600 dark:text-violet-400", label: "LLM" },
  tool: { icon: Wrench, bar: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", label: "Tool" },
  retrieval: { icon: Database, bar: "bg-sky-500", text: "text-sky-600 dark:text-sky-400", label: "Retrieval" },
} as const;

const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9000;
const START = 300;

function useDemoClock(enabled: boolean, loop: number) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [enabled]);
  useEffect(() => {
    if (!enabled || !visible) return;
    const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
    return () => clearInterval(id);
  }, [enabled, visible]);
  return { ref, t: elapsed % loop };
}

const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` : `${Math.round(ms)}ms`);

/** Live span waterfall for an agent run: LLM, tool, retrieval and sub-agent spans on one timeline, with a detail panel per span. */
export function TraceWaterfall({ traceId = "run_7f3a", spans = DEFAULT_SPANS, now: nowProp, defaultSelected, onSelect, autoPlay = false, className }: TraceWaterfallProps) {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const [touched, setTouched] = useState(false);
  const [picked, setPicked] = useState<string | null>(defaultSelected ?? null);
  const demo = autoPlay && !reduce && !touched && nowProp === undefined;
  const { ref, t } = useDemoClock(demo, LOOP);

  const total = Math.max(...spans.map((s) => s.start + s.duration));
  const now = demo ? Math.max(0, Math.min(total, t - START)) : (nowProp ?? total);
  const live = now < total;
  const selected = demo ? (t >= 4300 ? "s5" : null) : picked;
  const sel = spans.find((s) => s.id === selected);
  const errors = spans.filter((s) => s.error && now >= s.start + s.duration).length;
  const tokens = spans.filter((s) => (s.depth ?? 0) > 0 && now >= s.start + s.duration).reduce((a, s) => a + (s.tokens ?? 0), 0);

  const scaleMax = Math.ceil(total / 1000) * 1000;
  const pct = (ms: number) => (ms / scaleMax) * 100;
  const ticks = Array.from({ length: scaleMax / 1000 + 1 }, (_, i) => i * 1000);

  return (
    <div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
      <div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
        <div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-4 pb-3">
          <span className="relative grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-sm">
            <Activity className="size-4" aria-hidden />
            {live && !reduce && <span className="absolute -top-0.5 -right-0.5 size-2 animate-ping rounded-full bg-sky-500" aria-hidden />}
            {live && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-sky-500" aria-hidden />}
          </span>
          <div className="min-w-0 flex-1">
            <p className="text-sm font-semibold">Trace</p>
            <p className="truncate font-mono text-[10.5px] text-muted-foreground">
              {traceId} · {spans.filter((s) => now >= s.start).length} spans · {tokens.toLocaleString()} tok
              {errors > 0 && <span className="text-rose-600 dark:text-rose-400"> · {errors} error</span>}
            </p>
          </div>
          <span
            className={cn(
              "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 font-mono text-[10px] tabular-nums",
              live ? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300" : "bg-background text-muted-foreground",
            )}
            aria-live="polite"
          >
            {live && <span className={cn("size-1.5 rounded-full bg-sky-500", !reduce && "animate-pulse")} />}
            {live ? `Live ${fmt(now)}` : `Done ${fmt(total)}`}
          </span>
        </div>

        {/* Ruler */}
        <div className="flex items-end px-3 pb-1">
          <span className="w-[132px] shrink-0 pl-1 text-[10px] text-muted-foreground">Span</span>
          <div className="relative h-4 flex-1">
            {ticks.map((ms) => (
              <span key={ms} className="absolute bottom-0 -translate-x-1/2 font-mono text-[9px] text-muted-foreground tabular-nums first:translate-x-0 last:-translate-x-full" style={{ left: `${pct(ms)}%` }}>
                {ms / 1000}s
              </span>
            ))}
          </div>
        </div>

        {/* Rows */}
        <div className="relative px-3 pb-2" aria-label="Spans">
          <div className="pointer-events-none absolute inset-y-0 right-3 left-[144px]" aria-hidden>
            {ticks.map((ms) => (
              <span key={ms} className="absolute inset-y-0 w-px bg-border/60" style={{ left: `${pct(ms)}%` }} />
            ))}
            {live && (
              <motion.span
                className="absolute inset-y-0 w-px bg-sky-500"
                initial={false}
                animate={{ left: `${pct(now)}%` }}
                transition={{ duration: TICK / 1000, ease: "linear" }}
              >
                <span className="absolute -top-0.5 -left-[3px] size-[7px] rounded-full bg-sky-500 shadow-[0_0_0_3px_rgb(14_165_233/0.2)]" />
              </motion.span>
            )}
          </div>
          {spans.map((s) => {
            const started = now >= s.start;
            const end = Math.min(now, s.start + s.duration);
            const running = started && now < s.start + s.duration;
            const failed = !!s.error && !running && started;
            const k = KIND[s.kind];
            const Icon = failed ? AlertTriangle : k.icon;
            const isSel = s.id === selected;
            return (
              <button
                key={s.id}
                type="button"
                aria-pressed={isSel}
                aria-label={`${s.name}, ${k.label}, ${fmt(s.duration)}${s.error ? `, failed: ${s.error}` : ""}`}
                onClick={() => {
                  setTouched(true);
                  setPicked(isSel ? null : s.id);
                  if (!isSel) onSelect?.(s);
                }}
                className={cn(
                  "relative flex h-7 w-full cursor-pointer items-center rounded-md text-left outline-none transition-[background-color,opacity] duration-300 focus-visible:ring-2 focus-visible:ring-ring",
                  isSel ? "bg-muted" : "hover:bg-muted/50",
                  !started && "opacity-35",
                )}
              >
                <span className="flex w-[132px] shrink-0 items-center gap-1.5 pr-2" style={{ paddingLeft: 4 + (s.depth ?? 0) * 10 }}>
                  {(s.depth ?? 0) > 0 && <span className="h-px w-1.5 shrink-0 bg-border" aria-hidden />}
                  <Icon className={cn("size-3 shrink-0", failed ? "text-rose-500" : k.text)} aria-hidden />
                  <span className={cn("truncate font-mono text-[10.5px]", (s.depth ?? 0) === 0 && "font-semibold")}>{s.name}</span>
                </span>
                <span className="relative h-full flex-1">
                  {started && (
                    <motion.span
                      className={cn(
                        "absolute top-1/2 h-3.5 -translate-y-1/2 overflow-hidden rounded-[4px]",
                        failed ? "bg-rose-500" : k.bar,
                        (s.depth ?? 0) === 0 && !failed && "opacity-35",
                        isSel && "ring-2 ring-foreground/70 ring-offset-1 ring-offset-card",
                      )}
                      style={{ left: `${pct(s.start)}%` }}
                      initial={false}
                      animate={{ width: `${Math.max(pct(end - s.start), 0.8)}%` }}
                      transition={{ duration: demo ? TICK / 1000 : 0.5, ease: demo ? "linear" : EASE }}
                    >
                      {running && !reduce && <span className="absolute inset-0 animate-[tw-stripe_0.8s_linear_infinite] bg-[repeating-linear-gradient(-45deg,rgb(255_255_255/0.35)_0_4px,transparent_4px_8px)] bg-[length:11.3px_100%]" />}
                    </motion.span>
                  )}
                  {started && !running && (s.depth ?? 0) > 0 && (
                    <motion.span
                      initial={reduce ? false : { opacity: 0 }}
                      animate={{ opacity: 1 }}
                      className={cn("absolute top-1/2 -translate-y-1/2 pl-1 font-mono text-[9px] whitespace-nowrap tabular-nums", failed ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground")}
                      style={{ left: `${pct(s.start + s.duration)}%`, ...(pct(s.start + s.duration) > 80 ? { left: "auto", right: `${100 - pct(s.start)}%`, paddingRight: 4 } : {}) }}
                    >
                      {fmt(s.duration)}
                    </motion.span>
                  )}
                </span>
              </button>
            );
          })}
        </div>

        {/* Detail */}
        <AnimatePresence initial={false} mode="popLayout">
          {sel ? (
            <motion.div
              key={sel.id}
              id={`${uid}-detail`}
              initial={reduce ? false : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? undefined : { opacity: 0, y: -6 }}
              transition={{ duration: 0.3, ease: EASE }}
              className="border-t px-4 py-3"
            >
              <div className="mb-2 flex items-center gap-2">
                <span className={cn("rounded-full px-1.5 py-px text-[10px] font-medium", sel.error ? "bg-rose-500/10 text-rose-700 dark:text-rose-300" : "bg-muted text-muted-foreground")}>
                  {sel.error ? "Error" : KIND[sel.kind].label}
                </span>
                <span className="font-mono text-xs font-medium">{sel.name}</span>
                <span className="flex-1" />
                <span className="font-mono text-[10px] text-muted-foreground tabular-nums">
                  {fmt(sel.duration)}
                  {sel.tokens ? ` · ${sel.tokens.toLocaleString()} tok` : ""}
                </span>
              </div>
              <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-lg bg-muted/60 px-2.5 py-2 font-mono text-[10.5px]">
                <dt className="text-muted-foreground">in</dt>
                <dd className="truncate">{sel.input ?? "—"}</dd>
                <dt className="text-muted-foreground">out</dt>
                <dd className={cn("truncate", sel.error && "text-rose-600 dark:text-rose-400")}>{sel.error ? `${sel.error} · ${sel.output ?? ""}` : (sel.output ?? "—")}</dd>
              </dl>
            </motion.div>
          ) : (
            <motion.div
              key="legend"
              initial={reduce ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={reduce ? undefined : { opacity: 0 }}
              className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-4 py-2.5 text-[10.5px] text-muted-foreground"
            >
              {(Object.keys(KIND) as TraceSpanKind[]).map((key) => (
                <span key={key} className="inline-flex items-center gap-1.5">
                  <span className={cn("size-2 rounded-[3px]", KIND[key].bar)} />
                  {KIND[key].label}
                </span>
              ))}
              <span className="ml-auto">Click a span to inspect</span>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <style>{`@keyframes tw-stripe{to{background-position:11.3px 0}}`}</style>
    </div>
  );
}

export default function TraceWaterfallDemo() {
  return (
    <div className="flex h-[400px] w-full items-start justify-center pt-2">
      <TraceWaterfall autoPlay />
    </div>
  );
}
Sign in