Icon Mode Toggle

A compact icon-only segmented mode picker. Each entry renders as a square icon button; switching slides a shared highlight between segments via layoutId. Built for header strips, toolbars, and reader chrome where text labels would crowd the layout.

Customize
Shape
2
State
0

Installation

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

Usage

import { IconModeToggle } from "@craft-bits/core";
import { ListTree, Square } from "lucide-react";
 
<IconModeToggle
  aria-label="Display mode"
  defaultValue="continuous"
  modes={[
    { value: "continuous", label: "Scroll mode", icon: <ListTree /> },
    { value: "block", label: "Focus mode", icon: <Square /> },
  ]}
/>

Controlled — pair value with onChange:

const [value, setValue] = useState("continuous");
 
<IconModeToggle
  aria-label="Display mode"
  value={value}
  onChange={setValue}
  modes={[
    { value: "continuous", label: "Scroll mode", icon: <ListTree /> },
    { value: "block", label: "Focus mode", icon: <Square /> },
  ]}
/>

Understanding the component

  1. Icon-only by design. Where ModeToggle surfaces a label per segment, IconModeToggle collapses to just the glyph. The label field is still required — it becomes each segment's aria-label and title, so the action stays announceable and discoverable on hover.
  2. Shared moving indicator. Only the active segment renders a motion.span with a layoutId scoped to this instance via useId. Motion auto-tweens the highlight from the previous segment's bounds to the new one — SPRINGS.smooth does the easing.
  3. Layout-group isolation. The whole toggle is wrapped in a <LayoutGroup> keyed by the same scope, so multiple IconModeToggles on a page never collide on layoutId.
  4. Controlled + uncontrolled. value + onChange mirrors your state; defaultValue lets the component manage its own. Same pattern as Radix value / defaultValue.
  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.
  6. State hooks. Each segment exposes data-state="on" | "off", plus data-value carrying its id, so CSS and assistive tech can both observe the active mode.

Props

PropTypeDefaultDescription
modesIconModeToggleMode[]requiredOrdered list of mode definitions: value, icon, label.
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 / aria-checked reflecting its active state, plus data-state="on" | "off" for styling hooks.
  • Because no text label is visible, each mode's label becomes its aria-label and title so the action stays announceable and surfaces on hover.
  • 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 glyph sits on --cb-bg against the muted track; inactive glyphs use --cb-fg-muted and elevate to --cb-fg on hover — 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/IconModeToggle.tsx). The source was wired to a project-specific useLessonDisplayMode hook and a fixed ListTree / Square icon pair; craft-bits generalises it into a typed icon-mode picker with controlled+uncontrolled state, keyboard navigation, and token-driven styling.