Mode Toggle

A segmented mode picker. Each entry in modes becomes a button; switching slides a shared highlight between segments via layoutId. Built for view-mode pickers, theme switchers, layout choosers — anywhere the user moves between a small set of named states.

Customize
Shape
3
State
1

Installation

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

Usage

import { ModeToggle } from "@craft-bits/core";
 
<ModeToggle
  aria-label="View mode"
  defaultMode="grid"
  modes={[
    { id: "list", label: "List" },
    { id: "grid", label: "Grid" },
    { id: "board", label: "Board" },
  ]}
/>

Controlled — pair mode with onModeChange:

const [mode, setMode] = useState("grid");
 
<ModeToggle
  aria-label="View mode"
  mode={mode}
  onModeChange={setMode}
  modes={[
    { id: "list", label: "List" },
    { id: "grid", label: "Grid" },
    { id: "board", label: "Board" },
  ]}
/>

With icons — every mode accepts an optional icon:

<ModeToggle
  aria-label="View mode"
  defaultMode="grid"
  modes={[
    { id: "list", label: "List", icon: <ListIcon /> },
    { id: "grid", label: "Grid", icon: <GridIcon /> },
  ]}
/>

Understanding the component

  1. Single-prop API. modes is a flat array — the component owns the segmented layout, so a consumer just declares the modes they want. Each mode is { id, label, icon? }.
  2. Controlled + uncontrolled. mode + onModeChange makes it a controlled mirror of your state; defaultMode lets the component manage its own. Same pattern as Radix value / defaultValue.
  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 scope, so multiple ModeToggles 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-mode carrying its id, so CSS and assistive tech can both observe the active mode.

Props

PropTypeDefaultDescription
modesModeToggleMode[]requiredOrdered list of mode definitions: id, label, optional icon.
modestringControlled active mode id. Pair with onModeChange.
defaultModestringfirst modeUncontrolled initial active mode id.
onModeChange(mode: string) => voidFired when the active mode changes.
disabledbooleanfalseDisables every segment.
iconOnlybooleanfalseHide text labels; keep icons. Labels still drive each segment's accessible name.
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.
  • When iconOnly is set, the mode's label becomes its aria-label so the action stays announceable even though the text is visually hidden.
  • 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 sits on --cb-accent with --cb-accent-fg; 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: craftingattention (app/src/components/ui/ModeToggle.tsx). The source was a single light/dark icon flip-button; craft-bits generalizes it into a segmented multi-mode toggle with a sliding active indicator, controlled+uncontrolled state, keyboard navigation, and optional icons.