Construction Primitives
A five-piece set of lesson-chrome building blocks shared by every Predict / Challenge widget in the source projects. ModeStrip switches between modes, ChallengeBtn is the verb, FeedbackBadge reports the outcome of a check, ScoreDots tracks the running history, and DoneCard closes the run. Each primitive does one thing, picks tone from the cb-* semantic tokens, and respects prefers-reduced-motion.
Round 4 of 5
Nice!
2 correct of 3 (of 5)
Customize
Mode
predict
Progress
5
3
Installation
npx shadcn@latest add https://craftbits.dev/r/construction-primitives.jsonUsage
import {
ChallengeBtn,
DoneCard,
FeedbackBadge,
ModeStrip,
ScoreDots,
type ConstructionOutcome,
} from "@craft-bits/edu";
const MODES = [
{ key: "explore", label: "Explore" },
{ key: "predict", label: "Predict" },
{ key: "challenge", label: "Challenge" },
] as const;
function PredictRound() {
const [mode, setMode] = useState("predict");
const [results, setResults] = useState([]);
const [last, setLast] = useState(null);
const total = 5;
const done = results.length >= total;
const correct = results.filter((r) => r === "correct").length;
return (
<>
<ModeStrip modes={MODES} current={mode} onChange={setMode} />
{done ? (
<DoneCard score={correct} total={total} onRetry={() => setResults([])} />
) : (
<ChallengeBtn onClick={check}>Check</ChallengeBtn>
)}
<FeedbackBadge show={last !== null} outcome={last}>Nice!</FeedbackBadge>
<ScoreDots results={results} total={total} />
</>
);
}Anatomy
- ModeStrip —
role="radiogroup"with onerole="radio"button per option. Selected pill sits on acb-accent-tinted background with an inset 1px ring; the rest stay transparent and lift tocb-bg-mutedon hover. The generic<T extends string>keepscurrentandonChangestrictly typed against the option set. - ChallengeBtn — two tones, one shape, one size. Primary uses
cb-accent; secondary uses a muted surface. Press feedback iswhileTap: { scale: 0.96 }onSPRINGS.snap, short-circuited under reduced motion. Min hit area is 44px tall. - FeedbackBadge — outcome chip wrapped in
AnimatePresence. Correct outcomes pop with a tiny overshoot; incorrect outcomes pulse a 6-frame x-axis shake. Both routes shareSPRINGS.snap. Reduced motion collapses both to a no-overshoot fade. - ScoreDots — ordered row of small circles. Each dot paints in
cb-successorcb-error, and correct dots get a low-opacity halo viabox-shadow. Optionaltotalreserves placeholder dots at the right end. An SR-only summary line reports the running tally ("3 correct of 5"). - DoneCard — closing card with
role="status"andaria-live="polite". Reports{score}/{total}(or"Perfect!") and the percentage, plus an optional retry CTA. Compose any extra summary content viachildren.
Props
ModeStrip
| Prop | Type | Default | Description |
|---|---|---|---|
modes | ReadonlyArray<{ key: T; label: ReactNode; disabled?: boolean }> | required | Mode options. Order is preserved. |
current | T | required | Selected mode key. |
onChange | (mode: T) => void | required | Fired when the user picks a different mode. |
ariaLabel | string | 'Mode' | Accessible name for the strip. |
ChallengeBtn
| Prop | Type | Default | Description |
|---|---|---|---|
secondary | boolean | false | Render the muted tone. |
disabled | boolean | false | Native HTML disabled. |
children | ReactNode | required | Button label. |
FeedbackBadge
| Prop | Type | Default | Description |
|---|---|---|---|
show | boolean | required | Visibility. Mounts / unmounts via AnimatePresence. |
outcome | 'correct' | 'incorrect' | required | Drives the badge tone and animation. |
children | ReactNode | required | Badge content. |
ScoreDots
| Prop | Type | Default | Description |
|---|---|---|---|
results | ReadonlyArray<'correct' | 'incorrect' | boolean> | required | Outcomes already settled. |
total | number | — | Optional total slots. Greater than results.length fills the remainder with placeholders. |
ariaLabel | string | 'Score' | Accessible name for the row. |
DoneCard
| Prop | Type | Default | Description |
|---|---|---|---|
score | number | required | Correct rounds. |
total | number | required | Total rounds. |
onRetry | () => void | — | Optional retry handler. When omitted, the retry button is hidden. |
retryLabel | ReactNode | 'Try again' | Label for the retry button. |
title | ReactNode | — | Headline override. |
children | ReactNode | — | Optional body content between the headline and the retry CTA. |
Accessibility
ModeStripcarriesrole="radiogroup"; each pill carriesrole="radio"andaria-checked, so screen readers announce the active mode alongside the rest of the set.ChallengeBtnis a native<button>— keyboard activation, focus ring, anddisabledsemantics all come free.FeedbackBadgecarriesdata-outcomefor downstream styling hooks. Meaning is reinforced by the visible text content, not colour alone.ScoreDotsexposes an SR-only summary so assistive tech reports the running tally without scanning the visual circles.DoneCardisrole="status"+aria-live="polite", so the closing summary reads automatically the moment the run completes.- Motion is
transform/opacity/ colour only — neverwidth/height/top/left.prefers-reduced-motion: reducecollapses every animation to an instant resting state.
Credits
- Extracted from:
craftingattention(app/src/lessons/primitives/chrome/ConstructionPrimitives.tsx). The source file shipped the five UI primitives alongside theusePredictRoundsanduseChallengeStateround-state hooks; the hooks live separately in@craft-bits/coreso their state machines can be reused without dragging the chrome along. - The source's
ModePillhelper has been folded intoModeStripto keep the public surface at five components. The accent-rail colours have been retoned fromvar(--color-accent-*)tocb-accent, and theTIMING.correct.scaleBouncead-hoc transition is nowSPRINGS.snapto share the library's motion vocabulary.