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.jsonUsage
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
- Single-prop API.
modesis a flat array of{ value, label, icon? }— the component owns the segmented layout so a consumer just declares the modes they want. - Controlled + uncontrolled.
value+onChangemakes it a controlled mirror of your state;defaultValuelets the component manage its own. Same pattern as Radixvalue/defaultValue/onValueChange. - 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 sameuseIdscope, so multipleDisplayModeToggles 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-valuecarrying the segment's value, so CSS and assistive tech can both observe the active mode.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modes | DisplayModeToggleMode[] | required | Ordered list of mode definitions: value, label, optional icon. |
value | string | — | Controlled active mode value. Pair with onChange. |
defaultValue | string | first mode | Uncontrolled initial active mode value. |
onChange | (value: string) => void | — | Fired when the active mode changes. |
disabled | boolean | false | Disables every segment. |
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. - 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-fgon the elevated--cb-bgchip; 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:
algoflashcards(src/platform/ui/DisplayModeToggle.tsx). The source was a hard-wiredScroll | Focusswitch that bolted onto a project-specificuseLessonDisplayModehook. craft-bits generalizes it into a flat-array picker that accepts any number of{ value, label, icon? }modes, exposes a Radix-stylevalue/defaultValue/onChangeAPI, and keeps a sliding sharedlayoutIdhighlight scoped per instance.