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.jsonUsage
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
- Single-prop API.
modesis a flat array — the component owns the segmented layout, so a consumer just declares the modes they want. Each mode is{ id, label, icon? }. - Controlled + uncontrolled.
mode+onModeChangemakes it a controlled mirror of your state;defaultModelets the component manage its own. Same pattern as Radixvalue/defaultValue. - Shared moving indicator. Only the active segment renders a
motion.spanwith alayoutIdscoped to this instance viauseId. Motion auto-tweens that element from the previous segment's bounds to the new one —SPRINGS.smoothdoes the easing. - Layout-group isolation. The whole toggle is wrapped in a
<LayoutGroup>keyed by the same scope, so multipleModeToggles on a page never collide onlayoutId. - 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.
- State hooks. Each segment exposes
data-state="on" | "off"andaria-pressed, plusdata-modecarrying its id, so CSS and assistive tech can both observe the active mode.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modes | ModeToggleMode[] | required | Ordered list of mode definitions: id, label, optional icon. |
mode | string | — | Controlled active mode id. Pair with onModeChange. |
defaultMode | string | first mode | Uncontrolled initial active mode id. |
onModeChange | (mode: string) => void | — | Fired when the active mode changes. |
disabled | boolean | false | Disables every segment. |
iconOnly | boolean | false | Hide text labels; keep icons. Labels still drive each segment's accessible name. |
aria-label | string | — | Accessible name for the group. |
className | string | — | Merged onto the rendered group <div>. |
Accessibility
- The toggle renders
<div role="group">. Passaria-labelso the group is announced. - Each segment is a real
<button>witharia-pressedreflecting its active state anddata-state="on" | "off"for styling hooks. - When
iconOnlyis set, the mode'slabelbecomes itsaria-labelso 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-accentwith--cb-accent-fg; inactive labels use--cb-fg-mutedagainst--cb-bg-muted— both pass WCAG AA in the default theme. - The animated indicator respects
prefers-reduced-motion: Motion'slayoutIdtransition 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.