Skip to contentVibraUI
Feedback & status

Stage progress

How far a run has got — picking, packing, shipped, delivered — as a segment each.

A list rather than a bar, because every stage has a name and a state a reader has to be able to reach one at a time. The list carries the summary as its own name — "2 of 4: Packing" — so a row in a table announces where the order is without being opened, and each segment carries its state in words under the fill: Done, In progress, Not started, Blocked. Colour is never the only signal. How far the run has got counts everything finished plus the one being worked on, and a blocked stage counts the same way an active one does, because the run reached it and stopped rather than never arriving. Done takes the accent, since a stage bar is a progress fill and that is one of the accent's five roles; pending is the track, and only blocked spends a semantic tone, because it is the one state a reader has to act on. Active is the track under a half-width accent fill — the way an indeterminate progress bar is drawn, a convention for "started, not finished" rather than a claim that the stage is half done — and it is a shape rather than a colour on purpose: a state told apart from done only by a pulse would be indistinguishable from it the moment a reader turns motion off. The pulse over that fill is CSS: its period is a multiple of --duration-slow, which both the reader's own setting and a [data-motion="reduced"] wrapper zero, and each of the two stops it outright as well (motion-reduce, and in-data-[motion=reduced]); the fill stays either way. Nothing reads the media query in JavaScript, so a page can render a stage bar on the server.

Install

npx shadcn@latest add @vibra/stage-progress

Needs the @vibra registry in your components.json — set it up once.

Examples

Every state

A run at each point it can be at, and the small size beneath them.

Props

PropTypeDefaultDescription
stages{ id: string; label: string; state: "done" | "active" | "pending" | "blocked" }[]—The stages in order. Every one renders a segment and its state in words.
size"sm" | "default""default"sm drops the bar to 4px and the labels to 10px, for a bar inside a table row.

Dependencies

Registry

Source

components/ui/stage-progress.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

/** Where one stage of a run has got to. */
export type StageState = "done" | "active" | "pending" | "blocked"

export type StageProgressStage = {
  id: string
  label: string
  state: StageState
}

// Read out beside every segment, so the fill's colour is never the only signal.
const STATE_LABELS: Record<StageState, string> = {
  done: "Done",
  active: "In progress",
  pending: "Not started",
  blocked: "Blocked",
}

// Literal class names, one per state: Tailwind reads this file's source text,
// so a name built by interpolation would type-check and then render colourless.
// `done` takes the accent because a stage bar is a progress fill, which is one
// of the accent's five roles; `pending` and `active` are the track, and only
// `blocked` spends a semantic tone, because it is the one a reader must act on.
//
// The active segment is the track plus a part-width fill over it (below), not a
// colour of its own: a state told apart from `done` only by a pulse would be
// indistinguishable from it the moment a reader turns motion off.
const SEGMENT_TRACK: Record<StageState, string> = {
  done: "bg-brand",
  active: "bg-muted",
  pending: "bg-muted",
  blocked: "bg-danger",
}

const SEGMENT_HEIGHT = { default: "h-1.5", sm: "h-1" } as const

export type StageProgressProps = React.ComponentProps<"div"> & {
  stages: StageProgressStage[]
  size?: "sm" | "default"
}

/**
 * How far one run has got: picking → packing → shipped → delivered, as a
 * segment each.
 *
 * A list rather than a bar, because every stage has a name and a state a reader
 * has to be able to reach one at a time. The list carries the summary — "2 of 4:
 * Packing" — so a row in a table announces where the order is without being
 * opened, and each segment carries its own state in words under the fill.
 *
 * The active stage is the track under a half-width fill — a shape rather than a
 * colour of its own — and that fill pulses. The pulse is CSS: its period is a
 * multiple of `--duration-slow`, which both the reader's own setting and a
 * `[data-motion="reduced"]` wrapper zero, and each of the two stops it
 * outright as well; the fill stays either way, so `active` is still told from
 * `done` with the motion off. Nothing here reads the media query in
 * JavaScript, so a page can render a stage bar on the server.
 */
function StageProgress({
  className,
  stages,
  size = "default",
  ...props
}: StageProgressProps) {
  // How far the run has got: everything finished, plus the one being worked on.
  // A blocked stage has been reached too — the run stopped there rather than
  // never arriving — so it counts the same way the active one does.
  const done = stages.filter((stage) => stage.state === "done").length
  const current =
    stages.find((stage) => stage.state === "active") ??
    stages.find((stage) => stage.state === "blocked")
  const reached = Math.min(done + (current ? 1 : 0), stages.length)

  const summary = current
    ? `${reached} of ${stages.length}: ${current.label}`
    : `${reached} of ${stages.length}`

  return (
    <div
      data-slot="stage-progress"
      data-size={size}
      className={cn("w-full", className)}
      {...props}
    >
      <ol
        aria-label={summary}
        className={cn("flex w-full items-start", size === "sm" ? "gap-1" : "gap-1.5")}
      >
        {stages.map((stage) => (
          <li
            key={stage.id}
            data-slot="stage-progress-segment"
            data-state={stage.state}
            className="flex min-w-0 flex-1 flex-col gap-1"
          >
            <span
              className={cn(
                "relative w-full overflow-hidden rounded-full",
                SEGMENT_HEIGHT[size],
                SEGMENT_TRACK[stage.state]
              )}
            >
              {stage.state === "active" ? (
                <span
                  data-slot="stage-progress-fill"
                  aria-hidden="true"
                  // Half, the way an indeterminate progress bar is drawn — a
                  // rendering convention for "started, not finished", not a
                  // claim that the stage is half done.
                  //
                  // The pulse's period is four times --duration-slow, so the
                  // token both motion switches zero stops it as well as the
                  // media query does. The fill itself stays either way.
                  className="absolute inset-y-0 start-0 w-1/2 animate-pulse rounded-full bg-brand [animation-duration:calc(var(--duration-slow,320ms)*4)] motion-reduce:animate-none in-data-[motion=reduced]:animate-none"
                />
              ) : null}
            </span>

            <span
              className={cn(
                "truncate font-medium",
                size === "sm" ? "text-avatar" : "text-xs",
                stage.state === "pending" ? "text-muted-foreground" : "text-foreground"
              )}
            >
              {stage.label}
            </span>
            <span className="sr-only">{STATE_LABELS[stage.state]}</span>
          </li>
        ))}
      </ol>
    </div>
  )
}

export { StageProgress }