Display Mode Toggle

A compact two-or-more-segment toggle for swapping a content display mode — the kind of control that sits at the top-right of a long article and flips it between "Scroll" and "Focus", or above a board between "List" and "Board". The active segment carries a moving background highlight that tweens between positions via Motion's shared layoutId.

Customize
Shape
2
State
0

Installation

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

Usage

import { DisplayModeToggle } from "@craft-bits/core";
 
<DisplayModeToggle
  aria-label="Display mode"
  defaultValue="scroll"
  modes={[
    { value: "scroll", label: "Scroll" },
    { value: "focus", label: "Focus" },
  ]}
/>

Controlled — pair value with onChange:

const [mode, setMode] = useState("scroll");
 
<DisplayModeToggle
  aria-label="Display mode"
  value={mode}
  onChange={setMode}
  modes={[
    { value: "scroll", label: "Scroll" },
    { value: "focus", label: "Focus" },
  ]}
/>

With icons — every mode accepts an optional icon:

<DisplayModeToggle
  aria-label="View"
  defaultValue="list"
  modes={[
    { value: "list", label: "List", icon: <ListIcon /> },
    { value: "board", label: "Board", icon: <BoardIcon /> },
  ]}
/>

Understanding the component

  1. Single-prop API. modes is a flat array of { value, label, icon? } — the component owns the segmented layout so a consumer just declares the modes they want.
  2. Controlled + uncontrolled. value + onChange makes it a controlled mirror of your state; defaultValue lets the component manage its own. Same pattern as Radix value / defaultValue / onValueChange.
  3. Shared moving indicator. Only the active segment renders a motion.span with a layoutId scoped to this instance via useId. Motion auto-tweens that element from the previous segment's bounds to the new one — SPRINGS.smooth does the easing.
  4. Layout-group isolation. The whole toggle is wrapped in a <LayoutGroup> keyed by the same useId scope, so multiple DisplayModeToggles on a page never collide on layoutId.
  5. Keyboard model. Arrow Left / Up / Right / Down cycle focus through segments with wrap-around; Home / End jump to the ends. Selection only changes on click, Space, or Enter — focus alone never selects.
  6. State hooks. Each segment exposes data-state="on" | "off" and aria-pressed, plus data-value carrying the segment's value, so CSS and assistive tech can both observe the active mode.

Props

PropTypeDefaultDescription
modesDisplayModeToggleMode[]requiredOrdered list of mode definitions: value, label, optional icon.
valuestringControlled active mode value. Pair with onChange.
defaultValuestringfirst modeUncontrolled initial active mode value.
onChange(value: string) => voidFired when the active mode changes.
disabledbooleanfalseDisables every segment.
aria-labelstringAccessible name for the group.
classNamestringMerged onto the rendered group <div>.

Accessibility

  • The toggle renders <div role="group">. Pass aria-label so the group is announced.
  • Each segment is a real <button> with aria-pressed reflecting its active state and data-state="on" | "off" for styling hooks.
  • Arrow Left / Up / Right / Down cycle focus through segments with wrap-around. Home / End jump to the first / last.
  • Focus is visible via a focus-visible: ring keyed to --cb-accent.
  • Color contrast: active label uses --cb-fg on the elevated --cb-bg chip; inactive labels use --cb-fg-muted against --cb-bg-muted — both pass WCAG AA in the default theme.
  • The animated indicator respects prefers-reduced-motion: Motion's layoutId transition collapses to an instant swap.

Credits

  • Extracted from: algoflashcards (src/platform/ui/DisplayModeToggle.tsx). The source was a hard-wired Scroll | Focus switch that bolted onto a project-specific useLessonDisplayMode hook. craft-bits generalizes it into a flat-array picker that accepts any number of { value, label, icon? } modes, exposes a Radix-style value / defaultValue / onChange API, and keeps a sliding shared layoutId highlight scoped per instance.