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
- 2Trace it
- 3Spot the pattern
- 4Apply it
- 5Recap
Selected: trace
Customize
Phases
5
trace
Display
Installation
npx shadcn@latest add https://craftbits.dev/r/phase-ribbon.jsonUsage
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
- Root —
z-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. - Pill —
rounded-cb-xlblurred 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, thecompleted / totalcounter, and an animated chevron.whileTap={{ scale: 0.97 }}gives the tap-down feel. - Progress ring — an SVG circle with
strokeDasharray = circumferenceand an animatedstrokeDashoffsettweened onSPRINGS.smooth. At 100% the ring swaps to a filledbg-cb-successdisc with a check. - Expanded panel —
AnimatePresencewithinitial={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>witharia-current="step"(current), or a static<div>witharia-disabled="true"(upcoming / disabled). - Indicators —
ItemIndicatorrenders the success disc / accent dot / muted outline, all inbg-cb-success/bg-cb-accent/border-cb-border-strongtokens so the ribbon retones with the page theme.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
phases | PhaseRibbonPhase[] | required | Ordered phases. Each has id, label, and optional status / disabled. |
current | string | required | id of the active phase. Drives the collapsed label and the ring fraction. |
onSelect | (id: string) => void | — | Called when the user picks a non-disabled phase row. Receives the phase id. |
title | string | 'Phases' | Heading shown in the expanded panel and used to compose the trigger's accessible name. |
defaultExpanded | boolean | false | Initial expanded state. The ribbon stays uncontrolled — outside clicks and Escape always collapse it. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The collapsed trigger is a
<button>witharia-expanded,aria-controls, and anaria-labelthat announces the current phase, thecompleted / totalcount, and the expand/collapse action. - The expanded panel uses an
<ul>of<li>rows. The current row carriesaria-current="step"; disabled / upcoming rows carryaria-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'saria-labeland 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 hardcodedvar(--color-success-500)/var(--color-accent-500)references with acurrentIndexnumeric API and a non-existentSPRINGS.snappy. craft-bits' version retones tocb-*semantic tokens, swaps the numeric index for anid-driven API (current+onSelect(id)), derivesstatusfrom position when not provided, usesSPRINGS.snap/SPRINGS.smooth, wraps the component inforwardRef, and mergesclassNameviacn().