Progress Dots

A compact row of step-status dots — one per step. Each dot reflects one of four states (pending, current, correct, wrong). Pass onSelect to make the row tappable; pass results for per-step correct / wrong fills.

Customize
Steps
5
2
sm
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/progress-dots.json

Usage

import { ProgressDots } from "@craft-bits/core";
 
<ProgressDots total={5} defaultCurrent={2} />

Controlled flow — pair current with onSelect for a tappable stepper:

const [step, setStep] = useState(0);
 
<ProgressDots
  total={5}
  current={step}
  onSelect={setStep}
  results={[true, true, false]}
/>

Drop onSelect for a read-only progress indicator. Pass size="md" for slightly chunkier dots.

Understanding the component

  1. Four states per dot. pending, current, correct, wrong — surfaced as data-state on each dot so style overrides hook in cleanly.
  2. Controlled + uncontrolled. Pass current for controlled; otherwise the row tracks its own active index from defaultCurrent (Radix-style). The two modes never mix.
  3. results wins over current. A non-undefined entry in results paints correct / wrong regardless of current. Missing entries fall back to pending / current.
  4. Interactive mode. Passing onSelect switches the row to role="tablist" with role="tab" buttons, arrow / Home / End keyboard nav, and a roving tabIndex. Without it, the row is role="group" with plain <span> dots.
  5. Data hooks. The root carries data-cb-progress-dots, data-size, data-current, data-total. Each dot carries data-state and data-index so test selectors can target a specific step.

Variants

Passive indicator

<ProgressDots total={6} defaultCurrent={3} />

With per-step results

<ProgressDots
  total={5}
  defaultCurrent={3}
  results={[true, true, false, undefined, undefined]}
/>

Interactive (tappable)

<ProgressDots
  total={6}
  current={step}
  onSelect={setStep}
  size="md"
  aria-label="Pick a step"
/>

Props

PropTypeDefaultDescription
totalnumberrequiredNumber of dots to render.
currentnumberControlled active step index. Pair with onSelect.
defaultCurrentnumber0Uncontrolled initial active step index.
results(boolean or undefined)[]Per-step correct / wrong markers. Missing entries are pending / current.
onSelect(index: number) => voidTap / keyboard handler. Presence flips the row to interactive mode.
size'sm' or 'md''sm'Dot size. sm = 8px, md = 10px.
aria-labelstring'Step progress'Accessible label for the row.
classNamestringMerged onto the root via cn().

Accessibility

  • Passive rows render as role="group" with an aria-label; each dot is a span with a per-step aria-label like Step 2 of 5 — current.
  • Interactive rows render as role="tablist" with each dot as role="tab". Only the active tab has tabIndex={0} — focus rolls through with arrow keys, Home, and End.
  • Every interactive dot has a focus-visible ring (ring-cb-ring) for keyboard users.
  • Color contrast: accent (current) / success (correct) / error (wrong) all pass WCAG AA against cb-bg-elevated.
  • Motion is limited to a 150ms CSS transition on background-color / transform / opacity — well under the 300ms ceiling and inherently respectful of prefers-reduced-motion because no JS animation is involved.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/chrome/ProgressDots.tsx). The original was a thin wrapper over a StepChrome compound with track-accent / hex coloring and bespoke motion. craft-bits keeps the same shape (total, current, results) but trims the source-project context (StepChrome, accent hex, lesson-specific motion) and adds a tappable mode for stepper navigation.