Phase Ribbon
A wider, text-rich sibling of Step Progress. Each phase in a multi-stage flow renders as a labelled segment with an optional description; the current phase is highlighted with accent text plus a moving rail underneath. Past phases keep accent-tinted text and a small marker; future phases dim out.
Use it at the top of a long-running flow where the phases have names worth reading — lessons, deploy pipelines, wizards with chapters, documents with sections. Reach for Step Progress when you only need step count + cursor, or Step Timeline when the layout is vertical and you want numbered markers + connectors.
Installation
npx shadcn@latest add https://craftbits.dev/r/phase-ribbon.jsonUsage
Pass an ordered list of phases. The component is uncontrolled by default — the first phase id (or defaultCurrentPhase) sets the initial cursor.
import { PhaseRibbon } from "@craft-bits/core";
<PhaseRibbon
aria-label="Pipeline phases"
defaultCurrentPhase="build"
phases={[
{ id: "setup", label: "Setup", description: "Provision env + dependencies." },
{ id: "build", label: "Build", description: "Compile + bundle assets." },
{ id: "test", label: "Test", description: "Unit + integration suites." },
{ id: "deploy", label: "Deploy", description: "Promote to production." },
]}
/>Controlled — drive the cursor from external state:
const [phase, setPhase] = useState("build");
<PhaseRibbon
currentPhase={phase}
onCurrentPhaseChange={setPhase}
interactive
phases={[/* ... */]}
/>Interactive — phases become focusable tabs that the user can click or arrow-key between:
<PhaseRibbon interactive onCurrentPhaseChange={handleChange} phases={[/* ... */]} />Compact — hide descriptions and tighten padding for dense headers:
<PhaseRibbon compact phases={[/* ... */]} />Understanding the component
- Status derivation. Each phase's status is computed from its position in the
phasesarray relative to the resolved current phase id: indices before the current arepast, the current iscurrent, the rest arefuture. The component never throws on an unknowncurrentPhase— it just treats every phase asfutureuntil a valid id arrives. - Moving accent rail. The cursor under the active phase is a single absolutely-positioned
<span>with a sharedlayoutId. When the current phase changes, Motion's layout machinery slides it from the old position to the new onSPRINGS.smooth— transform only, no width animation. - Interactive vs read-only. When
interactiveisfalse(the default), each segment renders as a<span>and the ribbon is purely informational — useful as a header above the body of the current phase. Wheninteractiveistrue, segments becomerole="tab"buttons witharia-selected, full keyboard navigation (ArrowLeft / ArrowRight / Home / End), and rovingtabIndexso only the active tab takes focus on initial tab-in. - Compact mode.
compacthalves vertical padding and hides phase descriptions — useful when the ribbon sits inside an already-dense top bar. - Chevron connectors. Between segments, a small chevron renders in
--cb-fg-subtleas a decorativearia-hiddenglyph. The<ol>semantic already conveys order to assistive tech. - Reduced motion. When
prefers-reduced-motion: reduceis set, the rail snaps to the new position with no spring — the cursor still moves, but instantly.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
phases | { id: string; label: ReactNode; description?: ReactNode }[] | required | Ordered list of phases. |
currentPhase | string | — | Controlled current-phase id. Pair with onCurrentPhaseChange. |
defaultCurrentPhase | string | first phase id | Uncontrolled initial current-phase id. |
onCurrentPhaseChange | (id: string) => void | — | Fires when the user clicks a phase (only when interactive). |
interactive | boolean | false | Render phases as focusable tabs with keyboard nav. |
compact | boolean | false | Hide descriptions and tighten padding. |
className | string | — | Merged onto the root <nav> via cn(). |
aria-label | string | 'Phases' | Accessible name for the <nav> landmark. |
Accessibility
- Root renders as
<nav aria-label="Phases">. Override the label when domain-specific context helps ("Deploy phases"). - The list is a semantic
<ol>— screen readers announce position ("item 2 of 4") because the order is meaningful. - When
interactiveisfalse, each segment is a<span>witharia-current="step"on the active phase. Wheninteractiveistrue, segments are<button role="tab">witharia-selectedplusaria-current="step"on the active tab. - Roving tab index: in interactive mode, only the active tab carries
tabIndex={0}; ArrowLeft / ArrowRight cycle phases, Home / End jump to first / last. - Decorative elements (past-dot marker, chevron connector, accent rail) are
aria-hidden="true". - Each interactive segment has a hit area of at least 44 x 44 px.
prefers-reduced-motion: reduceshort-circuits the rail spring — the cursor jumps to the new phase instantly.- Contrast: current phase uses
--cb-accent; past phases--cb-fg-muted; future phases--cb-fg-subtle. All pass WCAG AA against the default surface in light and dark themes.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/chrome/PhaseRibbon.tsx). The source was a vertical floating-pill outliner — a collapsed pill that expanded into a vertical list of phases with circular progress + emerald checkmarks. craft-bits' version inverts the form into a horizontal text-rich ribbon that sits at the top of a flow, with an accent rail riding under the active phase vialayoutId. The project-specific status enum is replaced by derived past/current/future, and the SemanticHex palette by--cb-accenttokens.