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.jsonUsage
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
- Compound API.
Rootis the container + state-holder; eachOptionreads / writes the active value through a private React Context. Nooptionsarray — segments are children, so refactors are local to the JSX. - Controlled + uncontrolled.
value+onValueChangefor controlled,defaultValuefor uncontrolled. Same shape as@radix-ui/react-toggle-group. - Shared moving background. Only the active Option renders a
motion.spanwithlayoutId="cb-toggle-pill-active". Motion auto-tweens that element from the previous segment's bounds to the new one —SPRINGS.smoothdoes the easing. - Layout-group isolation. Each Root wraps its tree in a
<LayoutGroup>with a uniqueuseId()scope, so multiple TogglePills can coexist without theirlayoutIds colliding. - 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.
- State hooks. Each Option exposes
data-state="on" | "off"andaria-pressed, so CSS and assistive tech can both observe the active segment.
Props
TogglePill.Root
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled active option value. Pair with onValueChange. |
defaultValue | string | — | Uncontrolled initial active option value. |
onValueChange | (value: string) => void | — | Fired when the active option changes. |
disabled | boolean | false | Disables every option in the group. |
aria-label | string | — | Accessible name for the group. |
className | string | — | Merged onto the rendered <div role="group">. |
TogglePill.Option
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | The identifier this option contributes to the Root's value. |
disabled | boolean | false | Disable this segment only. |
className | string | — | Merged onto the rendered <button>. |
children | ReactNode | — | Segment label. |
...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | Any other <button> prop. |
Accessibility
- The Root renders
<div role="group">— passaria-labelso screen readers announce it. - Each Option is a real
<button>witharia-pressedreflecting its active state anddata-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-accentso users navigating by keyboard always see the current target. - 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 background respects
prefers-reduced-motion: Motion'slayoutIdtransition collapses to an instant swap.
Credits
- Extracted from:
craftingattention(app/src/components/ui/TogglePill.tsx). The original was a singlearia-pressedbutton; craft-bits generalizes it into a segmented compound with an animated sliding active background.