Lesson Display Mode

A focused radiogroup picker for "how does this lesson render?" preferences. Two to three options is the intended shape — each row carries a label, an optional one-line description, and an optional leading icon. The selected option gets an accent border, a tinted background, and a 3px rail that morphs between rows via a shared layoutId.

Reach for it when a lesson or problem-explainer needs a settings-panel toggle for content density — continuous-stack vs block-paginated, hint-on vs hint-off, narrate vs silent — and you want the option's description visible at decision time rather than buried in a tooltip.

Customize
Layout
vertical
Content

Installation

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

Usage

import { LessonDisplayMode } from "@craft-bits/edu";
 
<LessonDisplayMode
  aria-label="Lesson display mode"
  modes={[
    { value: "continuous", label: "Continuous", description: "Sections stack as you reveal them." },
    { value: "block",      label: "Block",      description: "Only the current section is visible." },
  ]}
  defaultValue="continuous"
  onChange={persist}
/>

Pair with a leading icon when the picker sits in a denser settings sheet:

<LessonDisplayMode
  aria-label="Render style"
  modes={[
    { value: "scroll", label: "Scroll", icon: <ScrollIcon /> },
    { value: "swipe",  label: "Swipe",  icon: <SwipeIcon /> },
  ]}
  orientation="horizontal"
/>

Anatomy

  • Rootrole="radiogroup", flex flex-col gap-2 (or flex-row in horizontal mode). Spreads unknown props onto a <div>.
  • Rowrole="radio" <button>, accent border + tinted background when selected, hover-darkened border otherwise. Roving tabindex keeps Tab to one stop per picker.
  • Accent rail — a motion.span with a shared layoutId scoped per-instance via useId(). Morphs between rows on SPRINGS.smooth; teleports under prefers-reduced-motion: reduce.
  • Icon badge — optional 9x9 leading badge that recolors with selection state — cb-accent on cb-bg when on, cb-fg-muted on cb-bg-muted/60 when off.
  • Label + descriptioncb-fg label on top, cb-fg-muted description below. The description truncates with in horizontal orientation so rows never overrun their siblings.
  • Check glyph — a small filled-circle indicator on the trailing edge of every row — outlined when off, cb-accent filled with a cb-accent-fg dot when on.

Props

PropTypeDefaultDescription
modesreadonly LessonDisplayModeOption[]requiredAvailable modes. Order is preserved.
valuestringControlled selected mode value. Pair with onChange.
defaultValuestringmodes[0].valueUncontrolled initial selected value.
onChange(value: string) => voidFired when the learner picks a new mode.
disabledbooleanfalseDisable every option. Individual option.disabled still wins per row.
orientation'vertical' | 'horizontal''vertical'Layout direction. Vertical wraps descriptions; horizontal truncates them.
aria-labelstringAccessible label for the picker group. Required when no visible label.
classNamestringMerged onto the root via cn().

LessonDisplayModeOption fields: value: string, label: ReactNode, description?: ReactNode, icon?: ReactNode, disabled?: boolean.

Accessibility

  • The root is a W3C radiogroup; each row is role="radio" with aria-checked. Pass aria-label (or aria-labelledby) so screen readers announce the group's purpose.
  • Keyboard: Tab lands on the selected (or first-enabled) option via roving tabindex. Arrow Up/Left and Arrow Down/Right cycle through enabled options and select on the way. Home / End jump to the first / last enabled option. Space activates the focused option without scrolling the page.
  • Focus: every row carries a 2px cb-accent ring on :focus-visible, with a cb-bg offset so the ring stays visible on every supported theme.
  • Motion: the only animated properties are transform (the tap scale) and the layoutId-driven rail position — never width / height / top / left. prefers-reduced-motion: reduce teleports the rail instead of sliding.
  • Contrast: the selected state uses cb-accent on cb-accent-muted/60, which clears WCAG AA at every supplied theme. The check glyph is a cb-accent-fg dot so it remains visible on the filled accent badge.

Credits

  • Extracted from: algoflashcards (src/platform/ui/LessonDisplayMode.tsx). The source paired a hardcoded two-mode union ('continuous' | 'block') with a localStorage-backed context provider, no visible chrome, and no keyboard handling — it shipped the mode state but not the picker UI. craft-bits' version drops the persistence concern entirely (the parent owns value / defaultValue), generalises to an arbitrary modes array with label + description + icon per row, adds full radiogroup keyboard navigation, and ships a sliding accent rail driven by motion's layoutId so the selection change feels like one continuous gesture.