Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, LoaderCircle, Minus, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** The status a stage reports. Everything here is decided by the system, never by the reader. */
export type StageStatus = "pending" | "running" | "succeeded" | "failed" | "skipped"

/**
 * What actually gets painted. `not-run` is derived, never passed in: once a
 * stage fails, the stages queued behind it are never going to start, and
 * painting them as "Pending" would promise a run that is already over.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/status-timeline.json

Prompt

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 useSyncExternalStore with the snapshot floored to the tick. Flooring is not cosmetic: React requires a cached snapshot, and a raw Date.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.

On This Page