Lesson Button

A compound API for the three button shapes that lesson and tutorial UIs need on repeat. Pill is the action button (Continue, Reset, Submit), Decision is a side-by-side pair for binary choices, Quiz is a vertical answer list with optional reveal feedback. All three sub-parts share a single CVA recipe so visual rhythm stays consistent across the trio.

Customize
Shape
pill
Quiz state

Installation

npx shadcn@latest add https://craftbits.dev/r/lesson-button.json

Usage

import { LessonButton } from "@craft-bits/core";
 
<LessonButton.Pill tone="accent">Continue</LessonButton.Pill>
 
<LessonButton.Decision
  leftLabel="Include"
  rightLabel="Exclude"
  onValueChange={setChoice}
/>
 
<LessonButton.Quiz
  options={[
    { value: "a", label: "O(n log n)" },
    { value: "b", label: "O(n)" },
  ]}
  correctValue="b"
  revealed={revealed}
  onValueChange={setPick}
/>

Understanding the component

  1. Shared CVA recipe. All three parts use one lessonButtonVariants recipe — tone (default / accent / success / error) and size map to the same cb-* semantic tokens that the base Button uses. The trio re-skins together when a consumer overrides --cb-accent (or any other token).
  2. Pill — Radix slot + Motion. Defaults to a motion.button with whileTap=TAP_SCALE and transition=SPRINGS.snap. Setting asChild switches to a static Radix Slot so the parent can supply an anchor (or any element) while keeping the styling. The loading prop renders an inline spinner and sets aria-busy.
  3. Decision — radiogroup + roving tabindex. Two equal-flex halves separated by a 1-px decorative divider. The selected side fills with cb-accent; the inactive side stays cb-bg-elevated. Arrow Left/Up commits the left side, Arrow Right/Down commits the right; Home / End jump to the ends. Only the selected (or first, when unset) button keeps tabIndex=0.
  4. Quiz — radiogroup + reveal feedback. Each option is a full-width motion.button inside role="radiogroup". The state recipe walks four mutually-exclusive cases in priority order: correct, wrong, selected, default. When revealed is true, the option matching correctValue paints cb-success; if the user's pick differs, their wrong choice paints cb-error. After reveal, the list is inert and the active focus ring is dismissed so correctness reads cleanly.
  5. Tap response is library-canonical. Every animated sub-part uses TAP_SCALE (0.96) and SPRINGS.snap from @craft-bits/core/motion — no inline whileTap scales, no hand-rolled spring objects.

Variants

// Pill tones
<LessonButton.Pill tone="default">Reset</LessonButton.Pill>
<LessonButton.Pill tone="accent">Continue</LessonButton.Pill>
<LessonButton.Pill tone="success">Submit</LessonButton.Pill>
<LessonButton.Pill tone="error">Delete</LessonButton.Pill>
 
// Pill sizes
<LessonButton.Pill size="sm">Small</LessonButton.Pill>
<LessonButton.Pill size="md">Medium</LessonButton.Pill>
<LessonButton.Pill size="lg">Large</LessonButton.Pill>
 
// Decision (controlled)
<LessonButton.Decision
  leftLabel="Yes"
  rightLabel="No"
  value={side}
  onValueChange={setSide}
/>
 
// Quiz with reveal
<LessonButton.Quiz
  options={options}
  value={pick}
  onValueChange={setPick}
  revealed={revealed}
  correctValue="b"
/>

Props

LessonButton.Pill

PropTypeDefaultDescription
tone'default' | 'accent' | 'success' | 'error''default'Visual tone — neutral, brand, or semantic.
size'sm' | 'md' | 'lg''md'Height + padding scale.
loadingbooleanfalseShow a spinner and disable interaction. Sets aria-busy.
asChildbooleanfalseRender content into the parent via Radix Slot.
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered element.
...restHTMLMotionProps<'button'>Any other motion.button prop.

LessonButton.Decision

PropTypeDefaultDescription
leftLabelReactNoderequiredLeft button content.
rightLabelReactNoderequiredRight button content.
value'left' | 'right' | nullControlled selection. null = nothing selected.
defaultValue'left' | 'right' | nullnullUncontrolled initial selection.
onValueChange(value: 'left' | 'right') => voidFired on selection.
disabledbooleanfalseDisable the entire pair.
aria-labelstringAccessible name for the radiogroup.
classNamestringMerged onto the rendered <div role="radiogroup">.

LessonButton.Quiz

PropTypeDefaultDescription
optionsreadonly LessonButtonQuizOption[]requiredAnswer choices. Order is preserved.
valuestringControlled selection. Pair with onValueChange.
defaultValuestringUncontrolled initial selection.
onValueChange(value: string) => voidFired when the user picks an option.
revealedbooleanfalseFreeze the list and paint correctness.
correctValuestringThe value considered correct. Required when revealed.
disabledbooleanfalseDisable every option.
aria-labelstringAccessible name for the radiogroup.
classNamestringMerged onto the rendered <div role="radiogroup">.

Accessibility

  • Pill renders a real <button> — focusable, fires on Enter / Space, participates in form submission. Provides aria-busy when loading. Focus ring uses var(--cb-accent) with a 2-px offset against cb-bg.
  • Decision is a role="radiogroup" with two role="radio" buttons and roving tabindex. Arrow Left/Up commits the left half; Arrow Right/Down commits the right; Home / End jump to the ends.
  • Quiz is a role="radiogroup" with one role="radio" per option. Arrow Up/Down (and Left/Right) move focus through the list; Home / End jump to the first / last. After revealed, options become inert and the focus ring is dismissed.
  • Color contrast: active fills sit on cb-accent / cb-success / cb-error against cb-accent-fg; muted states use cb-fg on cb-bg-elevated. All pairs pass WCAG AA in the default theme.
  • Motion respects prefers-reduced-motion: motion collapses whileTap to an instant snap and color transitions stay sub-300 ms (duration-150).
  • Minimum hit area: every interactive sub-part is min-h-[2.75rem] (44 px), meeting WCAG 2.5.8.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/chrome/LessonButton.tsx). The original wired in a project-specific playSound and a trackHex prop for per-lesson tinting. craft-bits generalizes the three shapes (pill / decision / quiz), re-paints them through the cb-* token system, drops the sound coupling, and adds full keyboard navigation + reveal feedback for the quiz.