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.

Customize
Current phase
build
Behavior

Installation

npx shadcn@latest add https://craftbits.dev/r/phase-ribbon.json

Usage

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

  1. Status derivation. Each phase's status is computed from its position in the phases array relative to the resolved current phase id: indices before the current are past, the current is current, the rest are future. The component never throws on an unknown currentPhase — it just treats every phase as future until a valid id arrives.
  2. Moving accent rail. The cursor under the active phase is a single absolutely-positioned <span> with a shared layoutId. When the current phase changes, Motion's layout machinery slides it from the old position to the new on SPRINGS.smooth — transform only, no width animation.
  3. Interactive vs read-only. When interactive is false (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. When interactive is true, segments become role="tab" buttons with aria-selected, full keyboard navigation (ArrowLeft / ArrowRight / Home / End), and roving tabIndex so only the active tab takes focus on initial tab-in.
  4. Compact mode. compact halves vertical padding and hides phase descriptions — useful when the ribbon sits inside an already-dense top bar.
  5. Chevron connectors. Between segments, a small chevron renders in --cb-fg-subtle as a decorative aria-hidden glyph. The <ol> semantic already conveys order to assistive tech.
  6. Reduced motion. When prefers-reduced-motion: reduce is set, the rail snaps to the new position with no spring — the cursor still moves, but instantly.

Props

PropTypeDefaultDescription
phases{ id: string; label: ReactNode; description?: ReactNode }[]requiredOrdered list of phases.
currentPhasestringControlled current-phase id. Pair with onCurrentPhaseChange.
defaultCurrentPhasestringfirst phase idUncontrolled initial current-phase id.
onCurrentPhaseChange(id: string) => voidFires when the user clicks a phase (only when interactive).
interactivebooleanfalseRender phases as focusable tabs with keyboard nav.
compactbooleanfalseHide descriptions and tighten padding.
classNamestringMerged onto the root <nav> via cn().
aria-labelstring'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 interactive is false, each segment is a <span> with aria-current="step" on the active phase. When interactive is true, segments are <button role="tab"> with aria-selected plus aria-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: reduce short-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 via layoutId. The project-specific status enum is replaced by derived past/current/future, and the SemanticHex palette by --cb-accent tokens.