Toggle Pill

A segmented "pill" toggle — multiple options sit side-by-side, one is active, and the highlight slides between them via layoutId. Composed as a Radix-style compound so the call-site reads like markup.

Customize
Shape
3
State
1

Installation

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

Usage

TogglePill is a compound — the Root owns the active value, each Option declares its own.

import { TogglePill } from "@craft-bits/core";
 
<TogglePill.Root defaultValue="m" aria-label="Size">
  <TogglePill.Option value="s">S</TogglePill.Option>
  <TogglePill.Option value="m">M</TogglePill.Option>
  <TogglePill.Option value="l">L</TogglePill.Option>
</TogglePill.Root>

Controlled mode — pair value with onValueChange:

const [size, setSize] = useState("m");
 
<TogglePill.Root value={size} onValueChange={setSize} aria-label="Size">
  <TogglePill.Option value="s">S</TogglePill.Option>
  <TogglePill.Option value="m">M</TogglePill.Option>
  <TogglePill.Option value="l">L</TogglePill.Option>
</TogglePill.Root>

Understanding the component

  1. Compound API. Root is the container + state-holder; each Option reads / writes the active value through a private React Context. No options array — segments are children, so refactors are local to the JSX.
  2. Controlled + uncontrolled. value + onValueChange for controlled, defaultValue for uncontrolled. Same shape as @radix-ui/react-toggle-group.
  3. Shared moving background. Only the active Option renders a motion.span with layoutId="cb-toggle-pill-active". Motion auto-tweens that element from the previous segment's bounds to the new one — SPRINGS.smooth does the easing.
  4. Layout-group isolation. Each Root wraps its tree in a <LayoutGroup> with a unique useId() scope, so multiple TogglePills can coexist without their layoutIds colliding.
  5. Keyboard model. Arrow Left/Right (and Up/Down) cycle focus through the Options 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 Option exposes data-state="on" | "off" and aria-pressed, so CSS and assistive tech can both observe the active segment.

Props

TogglePill.Root

PropTypeDefaultDescription
valuestringControlled active option value. Pair with onValueChange.
defaultValuestringUncontrolled initial active option value.
onValueChange(value: string) => voidFired when the active option changes.
disabledbooleanfalseDisables every option in the group.
aria-labelstringAccessible name for the group.
classNamestringMerged onto the rendered <div role="group">.

TogglePill.Option

PropTypeDefaultDescription
valuestringrequiredThe identifier this option contributes to the Root's value.
disabledbooleanfalseDisable this segment only.
classNamestringMerged onto the rendered <button>.
childrenReactNodeSegment label.
...restButtonHTMLAttributes<HTMLButtonElement>Any other <button> prop.

Accessibility

  • The Root renders <div role="group"> — pass aria-label so screen readers announce it.
  • Each Option 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 Options with wrap-around. Home / End jump to the first / last.
  • Focus is visible via a focus-visible: ring keyed to --cb-accent so users navigating by keyboard always see the current target.
  • 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 background respects prefers-reduced-motion: Motion's layoutId transition collapses to an instant swap.

Credits

  • Extracted from: craftingattention (app/src/components/ui/TogglePill.tsx). The original was a single aria-pressed button; craft-bits generalizes it into a segmented compound with an animated sliding active background.