One pill that springs between segments, with radio semantics and arrow keys.
01Preview
Checkout issues
Group by
Closed in the last30 days
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react03Usage
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
<SegmentedControl aria-label="View" defaultValue="board">
<SegmentedControlItem value="list" icon={<Menu />}>List</SegmentedControlItem>
<SegmentedControlItem value="board" icon={<Board />}>Board</SegmentedControlItem>
</SegmentedControl>
// Equal widths, full width
<SegmentedControl aria-label="Range" fill value={range} onValueChange={setRange}>
<SegmentedControlItem value="7d">7d</SegmentedControlItem>
<SegmentedControlItem value="30d">30d</SegmentedControlItem>
</SegmentedControl>04Source
"use client";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { motion, useReducedMotion } from "motion/react";
import { createContext, use, useId } from "react";
import { cn } from "@/lib/cn";
import { spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
type Size = "sm" | "md";
type Ctx = { value: string; size: Size; pill: string };
const SegmentedContext = createContext<Ctx>({ value: "", size: "md", pill: "" });
export type SegmentedControlProps = Omit<RadioGroup.Props<string>, "className" | "value" | "defaultValue" | "onValueChange"> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: Size;
/** Stretch to the container and give every segment the same width. */
fill?: boolean;
className?: string;
};
export function SegmentedControl({
value: valueProp,
defaultValue = "",
onValueChange,
size = "md",
fill = false,
className,
...rest
}: SegmentedControlProps) {
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
// One pill per control, shared by every segment through layoutId.
const pill = useId();
return (
<SegmentedContext value={{ value, size, pill }}>
<RadioGroup
value={value}
onValueChange={(next) => setValue(next as string)}
data-size={size}
className={cn(
"relative rounded-lg border border-line bg-page p-0.5",
fill ? "grid w-full auto-cols-fr grid-flow-col" : "inline-flex max-w-full",
size === "sm" ? "h-7 rounded-md" : "h-8",
className,
)}
{...rest}
/>
</SegmentedContext>
);
}
export type SegmentedControlItemProps = Omit<Radio.Root.Props<string>, "className" | "children" | "render" | "nativeButton"> & {
value: string;
/** 14px icon before the label. With no children, pass aria-label for an icon-only segment. */
icon?: React.ReactNode;
children?: React.ReactNode;
className?: string;
};
export function SegmentedControlItem({ value, icon, children, disabled, className, ...rest }: SegmentedControlItemProps) {
const { value: selected, size, pill } = use(SegmentedContext);
const reduce = useReducedMotion();
const active = selected === value;
const iconOnly = children == null;
return (
<Radio.Root
value={value}
disabled={disabled}
nativeButton
render={<button type="button" />}
// Disabled comes from the primitive so a disabled control dims every segment, not just flagged ones.
className={(state) =>
cn(
"group/seg relative flex h-full min-w-0 select-none items-center justify-center outline-none",
size === "sm" ? "rounded-[5px] text-[12px]" : "rounded-md text-[12.5px]",
iconOnly ? (size === "sm" ? "w-6" : "w-7") : size === "sm" ? "px-2" : "px-2.5",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"transition-colors duration-150",
active ? "text-fg" : "text-fg-3",
state.disabled ? "cursor-not-allowed opacity-50" : !active && !state.readOnly && "hover:text-fg-2",
state.disabled || state.readOnly ? "[--press:1]" : "[--press:0.96]",
className,
)
}
{...rest}
>
{active && (
<motion.span
layoutId={pill}
aria-hidden
className="absolute inset-0 border border-line-2 bg-raised shadow-[var(--shadow)]"
// Radius in style so Motion keeps the corners round while the pill stretches between segments of different widths.
style={{ borderRadius: size === "sm" ? 5 : 6 }}
transition={reduce ? { duration: 0 } : spring.snappy}
/>
)}
{/* The content presses; the pill stays put so it never looks like it jumped. */}
<span
className={cn(
"relative flex min-w-0 items-center font-medium tracking-[-0.005em] transition-[scale] duration-100 ease-out motion-safe:group-active/seg:scale-(--press)",
size === "sm" ? "gap-1" : "gap-1.5",
"[&_svg]:size-3.5 [&_svg]:shrink-0",
)}
>
{icon}
{children != null && <span className="truncate">{children}</span>}
</span>
</Radio.Root>
);
}05Props
SegmentedControl
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled selected segment. |
| defaultValue | string | — | Uncontrolled initial segment. |
| onValueChange | (value: string) => void | — | Called when the selection changes, by pointer or arrow key. |
| size | "sm" | "md" | "md" | 28px or 32px tall. |
| fill | boolean | false | Stretch to the container with equal-width segments; otherwise each segment fits its content. |
| disabled | boolean | false | Disables every segment: dimmed to 50%, no hover or press. |
| name | string | — | Form field name, submitted with the selected value. |
SegmentedControlItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The value this segment selects. |
| icon | ReactNode | — | Icon before the label, drawn at 14px with a 6px gap (4px at sm). |
| children | ReactNode | — | The label. Truncates when space runs out. Omit it and pass aria-label for an icon-only segment. |
| disabled | boolean | false | Dims the segment and skips it during arrow navigation. |
06Notes
Behavior
- The selected pill is a single element shared across segments, so a change reads as one thing moving, never two things fading.
- Fit mode sizes segments to their labels; fill mode splits the width evenly, which suits short, comparable options like ranges.
- Labels truncate before the control overflows; in fit mode the control never grows past its container.
Motion
- The pill moves with a layoutId animation on the snappy spring (520/38), about 200ms end to end, and interrupts cleanly mid-flight.
- The label and icon press to 0.96 in 100ms while the pill stays still; disabled and read-only segments do not press. Text color steps over 150ms. The pill's corner radius is corrected while it stretches between segments of different widths.
- Reduced motion moves the pill instantly and drops the press.
Accessibility
- role=radiogroup with role=radio segments rendered as native buttons; one tab stop, arrow keys move and select.
- Label the control with aria-label or aria-labelledby. Icon-only segments need their own aria-label.
- The focus ring sits 1px outside the pill so it stays visible on the selected segment.