Status Timeline
A read-only progress thread for an async run — icon plus word per stage, a live clock on the running one, expandable failures and a settled-run summary.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/status-timeline.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StatusTimeline" component — the progress
thread at the top of a deployment log: queued -> building -> deploying -> done
or failed. lucide-react for icons, cn() (clsx + tailwind-merge) for classes, no
other dependency. The reader can only watch: every state transition is decided
by the system that owns the job.
Contract
- export const StatusTimeline = React.forwardRef<HTMLDivElement, StatusTimelineProps>,
remaining props spread onto the root <div>. Props: stages: TimelineStage[];
orientation? "vertical" | "horizontal" (default "vertical"); label? (default
"Run stages", the accessible name of the list); showSummary? (default true);
defaultExpandedErrorIds?: string[]; tickInterval? (default 1000ms); now?: number
| Date; className.
- TimelineStage = { id: string; label: string; status: "pending" | "running" |
"succeeded" | "failed" | "skipped"; startedAt?: string | number | Date;
endedAt?: string | number | Date; detail?: string; error?: string }.
- Duplicate ids are dropped, first occurrence wins — they would collide on React
keys and on the aria-controls wiring, so toggling one error panel would toggle
its twin.
- The component owns exactly two pieces of state: which error panels are open,
and the live-region sentence. Stage data is 100% the consumer's.
Behavior
- Derived "not run": once a stage has failed, every LATER stage still reporting
"pending" is painted as "Not run" instead. Pending after a failure is a lie —
those stages are never going to start. A "running" stage after the failed one is
left alone (parallel jobs really can still be in flight), and "skipped" stays
"Skipped" because that was a decision, not a casualty.
- Status is carried by shape AND word, never by hue: solid hollow ring = Pending,
dashed ring = Not run, spinner = Running, check = Succeeded, cross = Failed,
dash = Skipped, each with its word rendered next to the label.
- Durations. A stage with startedAt+endedAt shows endedAt-startedAt, formatted as
"1.2s" under 10s, "42s", "3m 07s", "1h 12m" (tabular-nums so digits do not
dance). The running stage counts up live from startedAt. Negative or unparseable
timestamps are clamped/ignored rather than rendered as NaN.
- The clock is an external store read through useSyncExternalStore, NOT Date.now()
during render (render must stay pure) and NOT setState inside an effect. Two
details matter: (1) the snapshot is floored to the tick —
Math.floor(Date.now()/tick)*tick — because React requires getSnapshot to return
a cached value; a raw Date.now() trips "The result of getSnapshot should be
cached to avoid an infinite loop" and makes the consistency check force extra
renders (measured on React 19.2, 300 mounted clocks over 3s: 983 renders raw vs
exactly 900 floored), and the digits then also shift on unrelated parent
renders; (2) the server snapshot is null, so SSR and the first client paint
agree on "no clock yet" instead of hydrating a mismatched number. subscribe() only creates an interval
when some stage is actually running WITH a startedAt, so a finished run
schedules nothing; the interval is cleared on unsubscribe. tickInterval is
clamped to >= 100ms and 0 (or NaN/Infinity) freezes the clock entirely. Pass
`now` to pin the clock for tests, screenshots or server rendering.
- Announcements: ONE persistent polite region (role="status") plus ONE persistent
assertive region (role="alert"), both sr-only and both mounted from the start —
a live region created at the same moment as its text is usually not announced.
A sentence is written only when the *view signature* (the derived status of
every stage) changes, after a ~600ms quiet window so a burst of transitions
speaks once. Successes go polite ("Building: succeeded. Now running Deploying."),
failures go to the alert region, and the settled run gets one summary sentence.
The first paint is the baseline and is never announced — landing on a finished
run should not shout its outcome at you. Critically, the live elapsed time is
NOT inside either region: a per-second "41s… 42s…" is what makes screen readers
unusable on this kind of widget.
- Summary row: only when the run has settled (nothing pending, nothing running) —
"Completed in 4m 13s" / "Failed after 47.1s" plus the counts (succeeded, failed,
skipped, not run). The total span is max(endedAt) - min(startedAt), omitted when
the data has no usable timestamps.
- Failures: a failed stage with `error` gets a real <button aria-expanded
aria-controls> ("Show error" / "Hide error") and a monospace panel. The panel is
hidden with the `hidden` attribute, not a 0fr grid collapse — a collapsed grid
row still holds tab stops, so the trace would become an invisible keyboard trap.
Long lines wrap (whitespace-pre-wrap + break-words) and the panel scrolls at
max-h.
- Connector tint: a segment is tinted by the stage BEHIND it — primary after a
stage that succeeded, destructive after the stage that failed (it marks where
the run died), muted otherwise. Nothing about the connector implies the next
stage ran; the dashed ring and the word do that.
- Horizontal: same data as a strip — connector, node, label, word, duration
stacked per column. Columns keep a ~6.5rem floor and the strip scrolls instead
of squeezing: below that the status words of neighbouring stages collide
(measured: 5 stages at 390px gave 74px columns rendering "SucceededSucceeded").
Error panels render full width UNDER the strip (a stack trace in a
one-fifth-wide column is unreadable); aria-controls still binds them to their
trigger. Labels wrap inside their column (break-words).
- Empty stages[] renders a plain "No stages reported yet." line, with the live
regions still mounted.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground (succeeded node),
bg-destructive / text-background (failed node), bg-muted + text-muted-foreground
(skipped), border + border-dashed (pending vs not run), ring-primary/20 (running
halo), text-destructive + bg-destructive/5 + border-destructive/40 (error panel),
ring-ring for focus-visible. No hex, rgb() or oklch(); no chart tokens on text.
- A11y skeleton: <ol role="list"> with <li> as DIRECT children (a role-less div in
between makes screen readers announce an empty list), aria-current="step" on the
running stage, aria-label on the list. The spinner is aria-hidden — the word
"Running" is the accessible truth, and under prefers-reduced-motion the spinner
stops rotating but the row still reads as running.
- Motion budget is deliberately tiny: a spinning loader and a chevron rotation,
both motion-reduce:*-none. Nothing about the component depends on animation.
- Long labels wrap (min-w-0 + break-words) instead of widening the card; the
status + duration cluster is shrink-0 so it never gets squeezed out.
Customization levers
- Vocabulary: STATUS_TEXT maps each derived view to its word — translate it, or
say "Queued / In progress / Passed / Errored" to match your platform's language.
Announcement strings live in one pure function (announcementFor) if you localise.
- Iconography: STAGE glyphs and NODE_CLASS are two lookup tables. Swap the check
for a filled dot, give skipped a slash, or drop the ring border for a bare dot
timeline — the layout does not care.
- Time display: formatDuration is one function with an "exact"/"coarse" mode. Show
milliseconds for fast pipelines, or hide durations entirely and keep only the
words. tickInterval={0} freezes the clock for a static/SSR-only render.
- Chatter: ANNOUNCE_DELAY is the quiet window; raise it for chatty runners, or
drop both regions if the page already announces job state elsewhere.
- Density: size-6 nodes with pb-4 rows is the default; shrink to size-4/pb-2 for a
sidebar, or add a per-stage log excerpt under `detail` for a fuller panel.
- Layout: orientation="horizontal" for short-labelled pipelines (roughly 5 stages
or fewer); keep vertical when labels are long or stages carry detail lines. To
render both, drive them from the same stages array — the component is stateless
about progress.Concepts
- System-driven progression — the reader has no control here. Every transition arrives as data from the runner, which is exactly what separates this from a wizard stepper or a manual checklist.
- Derived not-run — a stage queued behind a failure is repainted as "Not run" rather than "Pending", so the UI stops promising work that will never start; deliberately skipped stages keep their own word.
- Cacheable clock snapshot — the live elapsed time comes from
useSyncExternalStorewith the snapshot floored to the tick. Flooring is not cosmetic: React requires a cached snapshot, and a rawDate.now()both trips that dev error and lets the clock shift on renders that have nothing to do with time. - Coalesced live region — one persistent polite region and one alert region, written only when the derived status of the run changes and only after a quiet window. The ticking seconds stay outside them, which is the difference between "one sentence per stage" and a per-second read-out.
- Shape plus word — status is encoded as ring / dashed ring / spinner / check / cross / dash and spelled out, so it survives a monochrome theme, a colour-blind reader and a screen reader alike.
- Settled-run summary — the footer only appears once nothing is pending or running, turning the thread into a receipt: total wall-clock span and how many stages succeeded, failed, were skipped or never ran.
Health Check List
A self-diagnostics panel — one independently re-runnable probe per row, shape-plus-word statuses, expandable evidence, real fix actions and a counts-and-verdict summary.
Diff Confirm
A confirmation dialog that shows the change list first — grouped create/update/delete counts, expandable before → after rows, type-to-confirm past a destructive threshold, and a failure state that keeps the plan on screen.