Phase Ribbon

A floating pill that shows where the reader is inside a phased explainer. Collapsed, it reads as a single row — a circular progress ring, the current phase's label, and a completed / total counter. Expanded, it opens a card listing every phase with a status indicator: a filled success disc for completed, an accent dot inside an accent outline for the current phase, and a muted outline circle for upcoming ones.

Reach for it when a lesson page has 3–7 phases and the reader needs to glance at "where am I, and what's left" without losing place in the body content.

Phases
  • Trace it
    2
  • Spot the pattern
    3
  • Apply it
    4
  • Recap
    5

Selected: trace

Customize
Phases
5
trace
Display

Installation

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

Usage

import { PhaseRibbon } from "@craft-bits/edu";
 
const PHASES = [
  { id: "setup", label: "The setup" },
  { id: "trace", label: "Trace it" },
  { id: "pattern", label: "Spot the pattern" },
  { id: "apply", label: "Apply it" },
  { id: "recap", label: "Recap" },
];
 
<PhaseRibbon
  phases={PHASES}
  current="trace"
  onSelect={(id) => setPhase(id)}
/>

Provide explicit status when the linear position is wrong — e.g. resuming a session where phases were completed out of order, or when later phases are locked:

<PhaseRibbon
  phases={[
    { id: "setup", label: "The setup", status: "completed" },
    { id: "trace", label: "Trace it", status: "completed" },
    { id: "pattern", label: "Spot the pattern", status: "current" },
    { id: "apply", label: "Apply it", status: "upcoming", disabled: true },
    { id: "recap", label: "Recap", status: "upcoming", disabled: true },
  ]}
  current="pattern"
  onSelect={setPhase}
/>

Anatomy

  • Rootz-30 mx-auto w-full max-w-[340px] floating wrapper, spreads unknown props onto a <div> and forwards a ref. Carries the click-outside listener while expanded.
  • Pillrounded-cb-xl blurred card with a layered shadow. The collapsed trigger and the expanded panel live inside.
  • Collapsed trigger — a <button> with the progress ring, the current phase's label, the completed / total counter, and an animated chevron. whileTap={{ scale: 0.97 }} gives the tap-down feel.
  • Progress ring — an SVG circle with strokeDasharray = circumference and an animated strokeDashoffset tweened on SPRINGS.smooth. At 100% the ring swaps to a filled bg-cb-success disc with a check.
  • Expanded panelAnimatePresence with initial={false} slides the panel down on first open without animating an already-open ribbon. Each row is either a <button> (completed → clickable), a static <div> with aria-current="step" (current), or a static <div> with aria-disabled="true" (upcoming / disabled).
  • IndicatorsItemIndicator renders the success disc / accent dot / muted outline, all in bg-cb-success / bg-cb-accent / border-cb-border-strong tokens so the ribbon retones with the page theme.

Props

PropTypeDefaultDescription
phasesPhaseRibbonPhase[]requiredOrdered phases. Each has id, label, and optional status / disabled.
currentstringrequiredid of the active phase. Drives the collapsed label and the ring fraction.
onSelect(id: string) => voidCalled when the user picks a non-disabled phase row. Receives the phase id.
titlestring'Phases'Heading shown in the expanded panel and used to compose the trigger's accessible name.
defaultExpandedbooleanfalseInitial expanded state. The ribbon stays uncontrolled — outside clicks and Escape always collapse it.
classNamestringMerged onto the root via cn().

Accessibility

  • The collapsed trigger is a <button> with aria-expanded, aria-controls, and an aria-label that announces the current phase, the completed / total count, and the expand/collapse action.
  • The expanded panel uses an <ul> of <li> rows. The current row carries aria-current="step"; disabled / upcoming rows carry aria-disabled="true" and render as non-interactive <div>s. Completed rows are real <button>s so they remain keyboard-reachable.
  • Every interactive row meets the 44px minimum hit area via min-h-[44px].
  • The progress ring and indicator glyphs are aria-hidden — they reflect facts already conveyed by the trigger's aria-label and the row labels.
  • Focus rings use focus-visible:ring-2 focus-visible:ring-cb-accent — visible only for keyboard focus.
  • Motion respects prefers-reduced-motion. The ring sweep, the chevron rotation, the panel height transition, and the tap-down scale all short-circuit to instant when reduced motion is requested.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/chrome/PhaseRibbon.tsx). The source paired hardcoded var(--color-success-500) / var(--color-accent-500) references with a currentIndex numeric API and a non-existent SPRINGS.snappy. craft-bits' version retones to cb-* semantic tokens, swaps the numeric index for an id-driven API (current + onSelect(id)), derives status from position when not provided, uses SPRINGS.snap / SPRINGS.smooth, wraps the component in forwardRef, and merges className via cn().