Trace Waterfall
A four-state request waterfall — nested spans on one shared relative clock, phase segments inside each bar, self-placing duration labels, an interval-attributed critical path ruled over exactly the stretch each span was blocking, and a collapsible tree the arrow keys can walk.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-trace-waterfall.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTraceWaterfall" card — a devtools
style request waterfall — as hand-rolled SVG over a zod contract. Recharts is
the wrong tool here: the picture is a tree of offset bars sliced into phases,
not a cartesian series.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
description?: string; budget?: number;
spans: { id: string; label: string; parent?: string;
start: number; duration: number;
phases?: { label: string; duration: number }[];
outcome?: "ok" | "warning" | "error";
detail?: string }[] }
- start and duration are MILLISECONDS ON THE TRACE CLOCK, not timestamps: a
trace is read relative to its own zero, and relative numbers survive being
logged, replayed and diffed. Nesting is by parent id, never by array index —
an index re-points silently the moment a span is inserted.
- Those numeric fields are z.union([z.number(), z.nan()]), not z.number(): zod 4
refuses NaN, so one span arriving with duration: NaN would fail the parse and
blank the whole card. A trace with one broken span is still a trace. Admit it
at the door and drop it — counted — in the layout.
- budget is a performance budget on the same clock, drawn as a dashed vertical
line. It is INJECTED. The component never calls Date.now(): a chart that reads
the clock while rendering draws one picture on the server and another in the
browser, and can never be snapshot-tested.
- Component props = z.infer of the schema plus density ("comfortable" |
"compact"), labelWidth (96-360), criticalPath (default true),
collapseBelowDepth, minBarWidth (default 2, clamped 1-12), locale
(default "en-US"), onSpanSelect, onRetry, emptyState and className, and it
forwardRefs onto the card with the rest of the native div props spread.
- Ship the layout maths as PURE EXPORTED FUNCTIONS beside the schema, so a test
can print the same numbers the picture is made of: buildTraceLayout()
returning { ok: true, layout } | { ok: false, issue }, plus mergeIntervals(),
coveredLength(), layoutPhases(), buildDurationTicks(), findCriticalPath(),
and, beside the component, placeDurationLabel() and phaseAppearance().
Behavior
- THE CRITICAL PATH IS AN INTERVAL ATTRIBUTION, not a chain of boxes. Walk
backwards from the root end: whatever ran last was the blocker; before it
started, whatever ended nearest that instant was the blocker; every gap the
children do not cover is the parent doing its own work. Recurse into each
blocker over exactly the window it blocked, visiting children latest-end
first so the walk can stop as soon as one ends before the window opens.
Two properties fall out and both are the point: the emitted stretches TILE
THE ROOT EXACTLY, so each span's share is a real percentage; and a slow span
that ran concurrently with a slower one gets nothing, which is the correct
answer, because shortening it would not have shortened the trace. Do NOT
ship the tempting shortcut — "descend into the last-finishing child" — it
cannot produce a percentage and it hands the crown to whichever
three-millisecond fire-and-forget call happened to finish last.
- SELF TIME per span is its length minus the union of its children clipped to
it. Union, not sum: two children that overlap must be counted once. Clip
first, or a child that escapes its parent drives self time negative.
- PHASES are lengths, not timestamps, laid end to end from the span start and
CLIPPED at the span end. A tracer measuring dns + tls + ttfb + download off
four clocks routinely reports a sum a few ms longer than the span it belongs
to; scaling the slices to fit silently rewrites every number, and letting
them run on draws a phase outside the span containing it. Clip, and report
the excess as overrunMs for the card to state out loud. The opposite gap —
phases summing to LESS than the span — is not an error and is never filled
in: the remainder stays bare track, which is exactly what "nobody attributed
this time" should look like.
- NOTHING IS DROPPED IN SILENCE. Count, and print under the header: spans whose
offset or length is not finite, repeated ids, spans naming a parent nobody
sent, parent chains that close a loop, and phase lengths that are not
numbers. Re-root the orphans and the loop members rather than discarding
their subtrees — a loop left in place is an infinite descent in every
consumer of the tree. A negative duration is clamped to an instant and
counted; a child that runs outside its parent is drawn where its numbers put
it and flagged, because tidying it away hides the bug the trace was opened to
find.
- REFUSE, don't fake it: if spans arrive but not one of them can be placed on a
clock, return { ok: false } and name the causes. That is neither "empty" nor
a failed fetch and must not be drawn as either.
- The four states are first-class branches of one bg-card panel: an indented
pulsing skeleton (aria-hidden, deterministic geometry, no timers), an empty
state, an error state carrying either the transport message or the layout's
refusal plus a "Try again" button only when onRetry exists, and ready.
- COLLAPSE is a real tree. Rows are depth-first; collapsing a span hides its
subtree, and the row keeps a dashed bracket spanning the whole hidden subtree
plus a +N count in the gutter, so folding never folds away the fact that work
happened out there. The critical-path rules of hidden spans roll up onto the
nearest visible ancestor. collapseBelowDepth is an INITIAL value, re-applied
when the span set changes — key that off the list of span ids, not off the
layout object, because the layout is rebuilt on every resize and object
identity would throw the reader's open/closed state away every time the card
changed size.
- Degenerate geometry must not break the picture: an empty span list is the
empty state; a single instantaneous span opens a one-millisecond window
rather than dividing by a zero-wide domain; a 0.4 ms span inside a 1.84 s
trace is 0.13px and is painted at minBarWidth, counted in a footnote that
admits its width no longer matches its duration.
Rendering & styling
- The time axis sits ON TOP, devtools style, with ticks on the 1 / 2 / 5 ladder
built as k · step from an integer k (repeated addition drifts to
1.2000000000000002 and prints fourteen decimals on a sub-millisecond trace),
and k = 0 normalised because Math.ceil hands back -0 and Intl prints that as
"-0". The axis keeps ONE unit for its whole length — ticks reading 900 ms,
1.00 s, 1.10 s are three units on one ruler — with precision derived from the
step. Bar durations do the opposite and follow their own magnitude: µs, ms
then s, so 420 µs and 1.84 s can sit in one card.
- PHASE COLOUR AND TEXTURE ADVANCE ON DIFFERENT CYCLES: colour is
var(--chart-1..5) by slot % 5, texture is one of five weaves by
floor(slot / 5) % 5. Twenty-five phases stay distinct, and any two stay
distinct with no hue at all. Cut the texture in var(--card) — the surface
every bar sits on, and the one colour guaranteed to contrast with all five
tokens in both themes; a --foreground weave vanishes on the dark end of the
light palette and the light end of the dark one.
- COLOUR IS NEVER THE ONLY CHANNEL: the phase name is printed inside any
segment wide enough to hold it (with a paint-order:stroke halo in --card, so
it reads over any fill without the chart guessing which foreground that fill
wants), every phase is named in the readout and the sr-only table, the
outcome is a shape before it is a colour (filled triangle for a failure,
hollow square for a warning), and the critical path is a rule, not a tint.
- The critical path is drawn as a foreground rule ABOVE each blocking stretch,
not as a highlight on the whole bar. The stretches tile the root, so the
ruled length IS the share; a bar with no rule at all is the finding.
- DURATION LABELS place themselves: after the bar, before it, or — when neither
gap holds the text — inside it, right-aligned against the bar end. One bar per
row means the only bounds are the plot edges, so a duration can never collide
with a neighbour. Estimate text width at 0.62 × font size, rounded UP from
the real average: an underestimate spills a label with no ellipsis to say so.
- INTERACTION, keyboard first. role="tree" on the svg with one tab stop and
aria-activedescendant rather than a tabindex per row (focus on an SVG child
is unevenly supported, and a 200-span trace would otherwise put 200 stops
between this card and whatever follows it). Each row is a role="treeitem"
with aria-level / aria-posinset / aria-setsize, because the DOM is flat.
↑ / ↓ move a row through what is on screen, → opens a closed span then steps
into its first child, ← closes an open one then climbs to its parent, Home /
End jump to the ends, Enter and Space fire onSpanSelect. The disclosure
triangle is clickable AND stops propagation so it never fires the row's
drill-down — but a pointer is never the only path to it.
- The readout line under the plot is aria-hidden on purpose: the focused row
already announces itself through aria-activedescendant and a live region
would say every number twice. Pointer moves and disclosure clicks do not move
focus, so those go to a polite sr-only status line — and pointing at a row
clears the last disclosure message, or a stale "collapsed" sits on top of
every hover for the rest of the session.
- Put sr-only on the WRAPPER DIV of the summary and the span table, never on
the table: CSS width is only a lower bound for a table box, so width:1px does
not hold one back and a 375px viewport picks up hundreds of px of horizontal
scroll.
- Axis and tick text is fill-muted-foreground at the density's font size,
gridlines are stroke-border, bars sit on a fill-muted track with a
stroke-muted-foreground outline. Only transitions are motion-safe by way of
motion-reduce:transition-none, and the chart is complete with animation off.
- Measure the container with a ResizeObserver and fall back to a fixed width
before the first measurement, so SSR and the first client render agree; the
tick target follows the measured width so a narrow card thins its axis
instead of stacking labels.
Customization levers
- density: "compact" halves the row rhythm and drops the in-segment phase names
for a sidebar; "comfortable" is the reading view. labelWidth overrides the
gutter independently, which is the knob to reach for when span names are
long — indentation is capped at half the gutter so a deep tree never pushes
the names out entirely.
- criticalPath={false} removes the rules and every sentence about them, for a
card that is only meant to show shape and order.
- collapseBelowDepth: 1 or 2 turns a 200-span trace into a readable outline;
omit it for traces small enough to read whole. Wire onSpanSelect to open your
own span detail — nothing is pinned in here.
- minBarWidth: 0-1 for strictly proportional widths (accept that sub-millisecond
spans disappear), 4-8 when the tail matters more than the arithmetic. It only
changes paint, never a number.
- budget: point it at your SLO to get the dashed line and the over / under
sentence; omit it and the chart says so rather than inventing a target.
- Palette: phases take var(--chart-1..5) by slot in first-appearance order.
Sort the phase slots by total time instead if you want the biggest phase to
own slot 1, or pin a fixed slot per known phase name so "db" is the same
colour across every card in a dashboard.
- Density of information: the phase legend, the drop note, the footnote and the
readout are four independent blocks — drop any of them for a compact card, as
long as the sr-only table stays, since it is the only place the exact numbers
are guaranteed to survive.Concepts
- Relative trace clock — every offset is milliseconds from the trace's own zero, so one shared ruler runs across all the rows and a bar starting further right started later. Nothing here is a wall-clock timestamp, which is what lets the same payload draw the same picture on the server, in the browser and in a snapshot next year; the only vertical reference, the budget line, is injected too.
- Interval-attributed critical path — walking backwards from the root end and recursing into each blocker over exactly the window it blocked. The stretches tile the root, so each span's contribution is a genuine percentage, and a span that ran alongside something slower gets nothing at all — which is the whole finding: shortening it would not have shortened the trace.
- Self time — a span's length minus the union of its children clipped to it. Union rather than sum, so two children that overlap are counted once; clipped first, so a child that escapes its parent cannot drive it negative. It is the answer to "did this layer wait, or did it work?".
- Phase segments and bare track — the ordered slices inside one bar, laid end to end from its start and clipped at its end. Phases that claim more than the span lasted are clipped and the excess is stated; phases that claim less leave the remainder as bare track, because "nobody attributed this time" is a real answer and painting over it would be a lie.
- Counted, never silent — a non-numeric offset, a repeated id, a parent nobody sent, a parent chain that loops, a negative length, a phase length that is not a number. Each one is counted and printed under the header; loops and orphans are re-rooted rather than discarded, since dropping them would take their subtrees with them.
- Roll-up on collapse — folding a subtree keeps its extent as a dashed bracket, its size as a
+Nin the gutter, and its critical stretches as rules on the nearest visible ancestor. Collapsing is a change of detail, never a change of total.
Percentile Bands
A four-state percentile fan: nested p50/p90/p99 bands over time with the median line on top, an SLO rule whose breach stretches are interpolated, counted and keyboard-reachable, and an optional log axis for a tail that dwarfs the median.
Climate Stripes
One stripe per period coloured by its distance from a baseline — a symmetric diverging ramp, a reference window that can be pinned so two strips compare, anchor labels, and unmeasured periods kept apart from average ones.