Segmented Control
A compact control for switching between a small set of mutually exclusive options with smooth animations and keyboard support.
import {
CalendarIcon,
ListIcon,
SquaresFourIcon,
} from '@phosphor-icons/react/dist/ssr';
import { SegmentedControl } from '@/components/segmented-control';
export default function SegmentedControlPreview() {
return (
<SegmentedControl>
<SegmentedControl.Item value="a">
<ListIcon />
<span>List</span>
</SegmentedControl.Item>
<SegmentedControl.Item value="b">
<SquaresFourIcon />
<span>Board</span>
</SegmentedControl.Item>
<SegmentedControl.Item value="c">
<CalendarIcon />
<span>Calendar</span>
</SegmentedControl.Item>
</SegmentedControl>
);
} Dependencies
Source Code
import { motion } from 'motion/react';
import {
Children,
createContext,
isValidElement,
use,
useCallback,
useId,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';
let segmentedControlInstanceCounter = 0;
interface SegmentedControlContextValue {
id: string;
segments: string[];
selectedSegment: string | undefined;
setSelectedSegment: (id: string) => void;
next: () => void;
previous: () => void;
registerSegment: (id: string) => () => void;
}
const SegmentedControlContext =
createContext<SegmentedControlContextValue | null>(null);
const useSegmentedControlContext = () => {
const context = use(SegmentedControlContext);
if (!context)
throw new Error(
'SegmentedControlContext must be used within a SegmentedControl component'
);
return context;
};
interface SegmentedControlProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange'> {
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
className?: string;
children: React.ReactNode;
}
const SegmentedControl = ({
defaultValue,
value: selectedValueProp,
onChange: onChangeProp,
className,
children,
...props
}: SegmentedControlProps) => {
const reactId = useId();
const [id] = useState(
() => `${reactId}-${++segmentedControlInstanceCounter}`
);
const [internalSelectedValue, setInternalSelectedValue] = useState<
string | undefined
>(defaultValue);
const [segments, setSegments] = useState<string[]>([]);
const explicitSelected = selectedValueProp ?? internalSelectedValue;
// Items register via useLayoutEffect, so on SSR / first paint `segments` is
// empty. Walk children directly to derive the default selection — otherwise
// every item gets `tabIndex=-1` until hydration, breaking keyboard entry.
const selectedSegment = useMemo(() => {
if (explicitSelected !== undefined) return explicitSelected;
if (segments.length > 0) return segments[0];
let first: string | undefined;
Children.forEach(children, (child) => {
if (first !== undefined) return;
if (
isValidElement<{ value?: unknown }>(child) &&
typeof child.props.value === 'string'
) {
first = child.props.value;
}
});
return first;
}, [explicitSelected, segments, children]);
useLayoutEffect(() => {
if (explicitSelected === undefined && segments.length > 0) {
setInternalSelectedValue(segments[0]);
}
}, [segments, explicitSelected]);
const setSelectedSegment = useCallback(
(value: string) => {
setInternalSelectedValue(value);
onChangeProp?.(value);
const itemId = getItemId(value);
if (itemId) {
document.getElementById(itemId)?.focus();
}
},
[onChangeProp]
);
const next = useCallback(() => {
const index = segments.indexOf(selectedSegment ?? '');
const nextSegment = segments[(index + 1) % segments.length];
if (nextSegment) setSelectedSegment(nextSegment);
}, [segments, selectedSegment, setSelectedSegment]);
const previous = useCallback(() => {
const index = segments.indexOf(selectedSegment ?? '');
const prevSegment = segments[index <= 0 ? segments.length - 1 : index - 1];
if (prevSegment) setSelectedSegment(prevSegment);
}, [segments, selectedSegment, setSelectedSegment]);
const registerSegment = useCallback((id: string) => {
setSegments((prev) => [...prev, id]);
return () => {
setSegments((prev) => prev.filter((prevId) => prevId !== id));
};
}, []);
const ctx = useMemo(
() => ({
id,
segments,
selectedSegment,
setSelectedSegment,
next,
previous,
registerSegment,
}),
[
id,
segments,
selectedSegment,
setSelectedSegment,
next,
previous,
registerSegment,
]
);
return (
<SegmentedControlContext value={ctx}>
<div
role="radiogroup"
className={cn(
'flex rounded-2xl bg-background-secondary p-1',
className
)}
{...props}
>
{children}
</div>
</SegmentedControlContext>
);
};
interface SegmentedControlItemProps
extends Omit<
React.ComponentPropsWithRef<'button'>,
'id' | 'type' | 'disabled'
> {
children: React.ReactNode;
asChild?: boolean;
value: string;
}
const getItemId = (id: string | undefined) =>
id ? `segment-${id}` : undefined;
const SegmentedControlItem = ({
children,
asChild,
onClick,
onKeyDown,
className,
value,
...props
}: SegmentedControlItemProps) => {
const {
id: segmentsId,
selectedSegment,
setSelectedSegment,
registerSegment,
next,
previous,
} = useSegmentedControlContext();
const Comp = asChild ? Slot : 'button';
useLayoutEffect(() => {
return registerSegment(value);
}, [value, registerSegment]);
const isSelected = selectedSegment === value;
const handleKeyboardNavigation = (
e: React.KeyboardEvent<HTMLButtonElement>
) => {
const keyOrientationMap = {
next: 'ArrowRight',
prev: 'ArrowLeft',
};
if (e.key === 'Enter' || e.key === ' ') {
setSelectedSegment(value);
}
if (keyOrientationMap.next === e.key) {
e.preventDefault();
next();
}
if (keyOrientationMap.prev === e.key) {
e.preventDefault();
previous();
}
};
return (
<Comp
id={getItemId(value)}
type="button"
className={cn(
'relative flex cursor-pointer items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-foreground/50 outline-none ring-ring transition hover:text-foreground focus-visible:ring-4 data-selected:text-foreground',
'[&>*:not([data-segment-indicator])]:z-10',
className
)}
role="radio"
aria-checked={isSelected}
data-selected={isSelected || undefined}
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) {
setSelectedSegment(value);
}
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!e.defaultPrevented) {
handleKeyboardNavigation(e);
}
}}
value={value}
{...props}
>
{typeof children === 'string' ? <span>{children}</span> : children}
{isSelected && (
<motion.span
data-segment-indicator="true"
layoutId={segmentsId}
aria-hidden="true"
className="absolute inset-0 z-0 rounded-xl bg-background"
transition={{ type: 'spring', duration: 0.3, bounce: 0.2 }}
/>
)}
</Comp>
);
};
const CompoundSegmentedControl = Object.assign(SegmentedControl, {
Item: SegmentedControlItem,
});
export { CompoundSegmentedControl as SegmentedControl }; Features
- Smooth Animations: Animated indicator transitions using Motion layout animations
- Keyboard Navigation: Full keyboard support with arrow key navigation
- Controlled & Uncontrolled: Flexible state management options
- ARIA Support: Full accessibility implementation with radiogroup role
- Custom Styling: Extensible styling through className props
Anatomy
<SegmentedControl>
<SegmentedControl.Item value="..." />
</SegmentedControl>
API Reference
SegmentedControl
The root container component that manages the state and behavior of the segmented control.
| Prop | Default | Type | Description |
|---|---|---|---|
defaultValue | - | string | The value of the segment that should be active by default. Defaults to the first segment if omitted. |
value | - | string | The controlled value of the active segment. |
onChange | - | (value: string) => void | Callback fired when the active segment changes. |
SegmentedControl.Item
The individual segment button component.
| Prop | Default | Type | Description |
|---|---|---|---|
value * | - | string | The value that identifies this segment. Used for selection state and keyboard navigation. |
asChild | - | boolean | Whether to merge props onto the child element. |
Accessibility
The Segmented Control implements the WAI-ARIA Radio Group Pattern. The root has role="radiogroup" and each item has role="radio" with aria-checked reflecting the current selection. Arrow keys move focus and selection between segments.
Examples
Basic Usage
A basic example of a segmented control.
import {
CalendarIcon,
ListIcon,
SquaresFourIcon,
} from '@phosphor-icons/react/dist/ssr';
import { SegmentedControl } from '@/components/segmented-control';
export default function SegmentedControlPreview() {
return (
<SegmentedControl>
<SegmentedControl.Item value="a">
<ListIcon />
<span>List</span>
</SegmentedControl.Item>
<SegmentedControl.Item value="b">
<SquaresFourIcon />
<span>Board</span>
</SegmentedControl.Item>
<SegmentedControl.Item value="c">
<CalendarIcon />
<span>Calendar</span>
</SegmentedControl.Item>
</SegmentedControl>
);
} Previous
Radio
Next
Select