What this solves
Information and gestalt hierarchy: the connector coloring groups completed stages from remaining ones without needing labels, so a reader answers "where is this job right now" from the shape alone, then confirms it from the stage names.
Use it when
- A job's own pipeline detail pagewalmart-mvp PipelineStepper.tsx: five clickable stages with per-step detail lines
- A row in a job list, showing the same chain in miniaturewalmart-mvp Jobs.tsx: the stepper rebuilt for job rows, pulsing on the active knob
- A gate-aware chain where an upstream stage can block the restspeedway WorkflowStageChain.tsx: the six-stage chain, gate-aware
Rendered
Fixture data; check both themes.- Ingestion
- Part types
- 3Scrape
- 4Attribute normalization
- 5Export
Mid-run: earlier stages are done, one stage is active.
Not started
- 1Ingestion
- 2Part types
- 3Scrape
- 4Attribute normalization
- 5Export
Mid-run
- Ingestion
- Part types
- 3Scrape
- 4Attribute normalization
- 5Export
Blocked at scrape
- Ingestion
- Part types
- Scrape
- 4Attribute normalization
- 5Export
Failed at scrape
- Ingestion
- Part types
- Scrape
- 4Attribute normalization
- 5Export
Attribute normalization skipped
- Ingestion
- Part types
- Scrape
- Attribute normalization
- Export
Complete
- Ingestion
- Part types
- Scrape
- Attribute normalization
- Export
Long chain (8 stages)
- Ingestion
- Validation
- Part types
- 4Scrape
- 5Normalize
- 6Attribute mapping
- 7QA review
- 8Export
patterns/workflow-stepper/example.tsxCopy this into a fresh route and it renders as above.
"use client";import { Button, RenderIcon } from "@versable-git/ui";import { useState } from "react";// items-start, not items-center: labels wrap to different heights, and// centering puts each knob at a different Y, which zigzags the connectors.// Advance and Block drive the same stages live, so mid-run, complete, and// blocked are all reachable from one interactive stepper. Failed and skipped// only ever appear in the static state gallery below.type StageState = "done" | "active" | "blocked" | "failed" | "skipped" | "pending";const initialLabels = ["Ingestion", "Part types", "Scrape", "Attribute normalization", "Export"];const longChainLabels = [ "Ingestion", "Validation", "Part types", "Scrape", "Normalize", "Attribute mapping", "QA review", "Export",];function initialStates(): StageState[] { return ["done", "done", "active", "pending", "pending"];}function Knob({ state, index }: { state: StageState; index: number }) { if (state === "done") { return ( <span className="bg-success text-success-content ring-success/25 flex size-6 items-center justify-center rounded-full ring-2"> <RenderIcon Icon="Success" size={14} /> </span> ); } if (state === "blocked") { return ( <span className="bg-warning text-warning-content flex size-6 items-center justify-center rounded-full"> <RenderIcon Icon="Warning" size={13} /> </span> ); } if (state === "failed") { return ( <span className="bg-error text-error-content flex size-6 items-center justify-center rounded-full"> <RenderIcon Icon="Error" size={13} /> </span> ); } if (state === "skipped") { return ( <span aria-label="skipped" className="border-base-content/30 flex size-6 items-center justify-center rounded-full border border-dashed" > <span aria-hidden className="bg-base-content/45 h-px w-2.5 rounded-full" /> </span> ); } if (state === "active") { // The ring lives on its own layer behind the number so its opacity can // pulse without dimming the digit sitting on top of it. return ( <span className="relative inline-flex"> <span aria-hidden="true" className="ring-primary/50 motion-safe:animate-pulse absolute inset-0 rounded-full ring-4" /> <span className="bg-primary text-primary-content relative flex size-6 items-center justify-center rounded-full text-xs font-semibold"> {index + 1} </span> </span> ); } return ( <span className="bg-base-300 text-base-content/65 flex size-6 items-center justify-center rounded-full text-xs font-semibold"> {index + 1} </span> );}function connectorClass(prev: StageState) { if (prev === "done") return "bg-success"; if (prev === "blocked") return "bg-warning"; if (prev === "failed") return "bg-error"; return "bg-base-300";}function labelClass(state: StageState) { if (state === "active") return "text-base-content font-medium"; if (state === "blocked") return "text-warning font-medium"; if (state === "failed") return "text-error font-medium"; return "text-base-content/65";}function phaseLabel(states: StageState[]) { if (states.some((s) => s === "blocked")) return "Blocked: the active stage needs attention, so the chain is paused."; if (states.every((s) => s === "done")) return "Complete: every stage finished."; return "Mid-run: earlier stages are done, one stage is active.";}/** The chain itself, stateless: the same markup renders live or as a snapshot. */export function Stepper({ states, labels = initialLabels }: { states: StageState[]; labels?: string[] }) { return ( <ol className="flex items-start" aria-label="Pipeline progress"> {states.map((s, i) => ( <li key={labels[i] ?? i} className="relative flex flex-1 flex-col items-center gap-2"> {i > 0 ? ( <span aria-hidden="true" className={`absolute top-3 right-1/2 left-[-50%] h-0.5 ${connectorClass(states[i - 1]!)}`} /> ) : null} <span className="relative z-10"> <Knob state={s} index={i} /> </span> <span className={`px-1 text-center text-xs ${labelClass(s)}`}>{labels[i]}</span> </li> ))} </ol> );}/** The state gallery: every shape the chain can take, so variance is visible without clicking. */export function StepperStates() { const rows: { label: string; states: StageState[]; labels?: string[] }[] = [ { label: "Not started", states: ["pending", "pending", "pending", "pending", "pending"] }, { label: "Mid-run", states: ["done", "done", "active", "pending", "pending"] }, { label: "Blocked at scrape", states: ["done", "done", "blocked", "pending", "pending"] }, { label: "Failed at scrape", states: ["done", "done", "failed", "pending", "pending"] }, { label: "Attribute normalization skipped", states: ["done", "done", "done", "skipped", "done"] }, { label: "Complete", states: ["done", "done", "done", "done", "done"] }, { label: "Long chain (8 stages)", states: ["done", "done", "done", "active", "pending", "pending", "pending", "pending"], labels: longChainLabels, }, ]; return ( <div className="flex flex-col gap-5"> {rows.map((r) => ( <div key={r.label} className="flex flex-col gap-2"> <span className="text-base-content/70 text-xs font-semibold tracking-wide uppercase">{r.label}</span> <Stepper states={r.states} labels={r.labels} /> </div> ))} </div> );}export function WorkflowStepper() { const [states, setStates] = useState<StageState[]>(initialStates); const activeIndex = states.findIndex((s) => s === "active"); const canAdvance = activeIndex !== -1; function advance() { setStates((prev) => { const next = [...prev]; const i = next.findIndex((s) => s === "active"); if (i === -1) return prev; next[i] = "done"; if (i + 1 < next.length) next[i + 1] = "active"; return next; }); } function block() { setStates((prev) => { const next = [...prev]; const i = next.findIndex((s) => s === "active"); if (i === -1) return prev; next[i] = "blocked"; return next; }); } function reset() { setStates(initialStates()); } return ( <div className="flex flex-col gap-4"> <Stepper states={states} /> <p className="text-base-content/70 text-sm">{phaseLabel(states)}</p> <div className="flex gap-2"> <Button size="sm" content="Advance" onClick={advance} disabled={!canAdvance} /> <Button size="sm" variant="outline" color="warning" content="Mark blocked" onClick={block} disabled={!canAdvance} /> <Button size="sm" variant="text" shade content="Reset" onClick={reset} /> </div> </div> );}Where it ships
walmart-mvp/frontend/src/features/catalog/PipelineStepper.tsx:34-136five clickable stages with connector halves and per-step detail lineswalmart-mvp/frontend/src/pages/Jobs.tsx:364-458the same shape independently rebuilt for job stages, with a pulse on the active knobspeedway/app/components/WorkflowStageChain.tsx:560-593the knob-and-bar row of the six-stage chain, gate-aware
App-specific: Real steppers make knobs clickable with upstream-blocking rules, pulse the halted gate, and pair the row with a note banner and a per-stage detail list. Both apps hand-built this shape independently, which is why it is a pattern page and a standing kit-component candidate.