Pattern gallery

Workflow stepper

One glance answers where a job sits, what finished, and what comes next.

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.
  1. Ingestion
  2. Part types
  3. 3Scrape
  4. 4Attribute normalization
  5. 5Export

Mid-run: earlier stages are done, one stage is active.

Not started
  1. 1Ingestion
  2. 2Part types
  3. 3Scrape
  4. 4Attribute normalization
  5. 5Export
Mid-run
  1. Ingestion
  2. Part types
  3. 3Scrape
  4. 4Attribute normalization
  5. 5Export
Blocked at scrape
  1. Ingestion
  2. Part types
  3. Scrape
  4. 4Attribute normalization
  5. 5Export
Failed at scrape
  1. Ingestion
  2. Part types
  3. Scrape
  4. 4Attribute normalization
  5. 5Export
Attribute normalization skipped
  1. Ingestion
  2. Part types
  3. Scrape
  4. Attribute normalization
  5. Export
Complete
  1. Ingestion
  2. Part types
  3. Scrape
  4. Attribute normalization
  5. Export
Long chain (8 stages)
  1. Ingestion
  2. Validation
  3. Part types
  4. 4Scrape
  5. 5Normalize
  6. 6Attribute mapping
  7. 7QA review
  8. 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 lines
  • walmart-mvp/frontend/src/pages/Jobs.tsx:364-458the same shape independently rebuilt for job stages, with a pulse on the active knob
  • speedway/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.

@versable-git/ui · composites proven in the apps