Build an Accessible React Segmented Control in React and TypeScript
Build an accessible, keyboard-friendly React segmented control with TypeScript: API design, styling, form integration, tests, and enhancements.
Image used for representation purposes only.
Overview
A segmented control lets users switch between a small set of mutually exclusive options in a single horizontal control. It’s perfect for view toggles (List/Grid), content filters (Week/Month), and mode pickers (Light/Dark/System). In React, you’ll often see segmented controls implemented as a row of buttons—but the most accessible approach is to build them on top of native radio inputs. That way you get keyboard behavior, form participation, and screen reader support out of the box.
This article walks you through designing an API, building a headless and styled React component with TypeScript, wiring up keyboard and screen reader support, adding tests, and considering edge cases like RTL and overflow.
When to use a segmented control
Use a segmented control when:
- There are 2–5 mutually exclusive options.
- The action is immediate (no Apply button).
- The change is reversible and safe.
Avoid it when the option count is large (use a select or tabs) or when choices trigger destructive behavior (use an explicit confirmation flow).
Accessibility first: semantics and keyboard interaction
For mutually exclusive selection, radio inputs are the right semantic primitive.
- Semantics: Use native
<input type="radio">with the samenameattribute; optionally group them in a<fieldset>with a<legend>(or usearia-label/aria-labelledby). - Screen readers: Radios announce the current selection and the total count (e.g., “Grid, radio button, 2 of 3”).
- Keyboard: When a radio is focused, Arrow Left/Right (and Up/Down) move selection; Space/Enter activates. Tab moves out of the group.
Tip: You can style labels while visually hiding the radio inputs—but don’t display: none them. Use a visually-hidden technique so they remain focusable and accessible.
API design
A small, predictable API covers most use cases:
options: array of{ label, value, disabled? }.value(controlled) and/ordefaultValue(uncontrolled).onValueChange(value)callback.namefor form integration (optional; generated if omitted).sizeandclassNamefor styling.ariaLabelorariaLabelledByfor accessible naming.
Implementation (TypeScript, headless core)
Below is a headless implementation that preserves native form behavior and accessibility while leaving styling up to you.
// SegmentedControl.tsx
import React from 'react';
type Option<T extends string> = {
label: React.ReactNode;
value: T;
disabled?: boolean;
};
export type SegmentedControlProps<T extends string> = {
options: Option<T>[];
value?: T; // controlled
defaultValue?: T; // uncontrolled
onValueChange?: (value: T) => void;
name?: string;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
ariaLabel?: string;
ariaLabelledBy?: string;
className?: string;
};
export function SegmentedControl<T extends string>(props: SegmentedControlProps<T>) {
const {
options,
value,
defaultValue,
onValueChange,
name,
disabled,
size = 'md',
ariaLabel,
ariaLabelledBy,
className,
} = props;
const generatedName = React.useId();
const groupName = name ?? `segmented-${generatedName}`;
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState<T | undefined>(defaultValue ?? options[0]?.value);
const selected = (isControlled ? value : internal) as T | undefined;
const handleChange = (next: T) => {
if (!isControlled) setInternal(next);
onValueChange?.(next);
};
return (
<div
className={['sc-root', `sc-${size}`, className].filter(Boolean).join(' ')}
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-disabled={disabled || undefined}
data-disabled={disabled ? '' : undefined}
>
{options.map((opt, i) => {
const id = `${groupName}-${i}`;
const checked = selected === opt.value;
return (
<div key={id} className="sc-item">
<input
className="sc-radio"
type="radio"
id={id}
name={groupName}
value={opt.value}
checked={checked}
onChange={() => handleChange(opt.value)}
disabled={disabled || opt.disabled}
/>
<label
className="sc-label"
htmlFor={id}
data-state={checked ? 'checked' : 'unchecked'}
data-disabled={disabled || opt.disabled ? '' : undefined}
>
{opt.label}
</label>
</div>
);
})}
</div>
);
}
This component:
- Uses native radios for semantics and keyboard support.
- Supports controlled and uncontrolled usage.
- Emits
onValueChangeonly on user selection.
Styling a modern segmented control
The CSS below applies a pill background, smooth color transitions, focus-visible rings, and disabled states. It uses CSS custom properties for easy theming.
/* SegmentedControl.css */
.sc-root {
--sc-bg: hsl(0 0% 97%);
--sc-fg: hsl(222 47% 11%);
--sc-muted: hsl(215 16% 47%);
--sc-accent: hsl(222 88% 55%);
--sc-ring: color-mix(in oklab, var(--sc-accent), white 70%);
--sc-radius: 9999px;
--sc-gap: 2px;
display: inline-flex;
align-items: stretch;
background: var(--sc-bg);
padding: var(--sc-gap);
border-radius: var(--sc-radius);
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.sc-sm { font-size: 12px; }
.sc-md { font-size: 14px; }
.sc-lg { font-size: 16px; }
.sc-item { position: relative; }
/* Visually hide but keep accessible */
.sc-radio {
position: absolute;
inset: 0;
width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
.sc-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 8px 14px;
border-radius: calc(var(--sc-radius) - var(--sc-gap));
color: var(--sc-muted);
transition: color .2s ease, background-color .2s ease, box-shadow .2s ease;
cursor: pointer;
}
.sc-label[data-state="checked"] {
background: white;
color: var(--sc-fg);
box-shadow: 0 1px 0 rgba(0,0,0,.05), 0 0 0 1px rgba(0,0,0,.02);
}
/* Hover/active feedback */
.sc-label:hover { color: var(--sc-fg); }
.sc-label:active { filter: brightness(.98); }
/* Focus ring on the visual label when the radio receives focus */
.sc-radio:focus-visible + .sc-label {
outline: 0;
box-shadow: 0 0 0 3px var(--sc-ring);
}
/* Disabled states */
.sc-root[data-disabled] .sc-label,
.sc-label[data-disabled] {
cursor: not-allowed;
opacity: .6;
}
@media (prefers-reduced-motion: reduce) {
.sc-label { transition: none; }
}
/* Dark mode baseline */
@media (prefers-color-scheme: dark) {
.sc-root { --sc-bg: hsl(220 11% 16%); --sc-fg: hsl(0 0% 98%); --sc-muted: hsl(215 11% 72%); }
.sc-label[data-state="checked"] { background: hsl(220 11% 12%); box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
}
Usage:
import { SegmentedControl } from './SegmentedControl';
import './SegmentedControl.css';
const options = [
{ label: 'List', value: 'list' },
{ label: 'Grid', value: 'grid' },
{ label: 'Compact', value: 'compact', disabled: false },
] as const;
type View = typeof options[number]['value'];
export default function Example() {
const [view, setView] = React.useState<View>('list');
return (
<div>
<SegmentedControl
options={options}
value={view}
onValueChange={setView}
ariaLabel="View mode"
/>
<div style={{ marginTop: 16 }}>Current: {view}</div>
</div>
);
}
Controlled, uncontrolled, and forms
- Controlled: Pass
valueandonValueChangeto sync with React state. - Uncontrolled: Pass
defaultValue; omitvalue. - HTML forms: Provide a
nameprop and the selected radio will submit like any other input.
Example (native form submission):
<form action="/search" method="GET">
<SegmentedControl
name="view"
options={[
{ label: 'All', value: 'all' },
{ label: 'Images', value: 'images' },
{ label: 'Videos', value: 'videos' },
]}
defaultValue="all"
ariaLabel="Result type"
/>
<button type="submit">Search</button>
</form>
Enhancements: animated thumb (optional)
Many segmented controls include an animated “thumb” that slides under the selected option. You can add one by measuring the active label and setting CSS variables.
// Inside SegmentedControl, optional thumb
const itemRefs = React.useRef<HTMLLabelElement[]>([]);
const [thumb, setThumb] = React.useState({ left: 0, width: 0 });
React.useLayoutEffect(() => {
const el = itemRefs.current.find((n) => n?.dataset.state === 'checked');
if (!el) return;
const parent = el.parentElement?.parentElement as HTMLElement; // .sc-root
const rect = el.getBoundingClientRect();
const rootRect = parent.getBoundingClientRect();
setThumb({ left: rect.left - rootRect.left, width: rect.width });
}, [selected, options.length]);
// Render the thumb element
<div className="sc-thumb" style={{ transform: `translateX(${thumb.left}px)`, width: thumb.width }} />
.sc-root { position: relative; }
.sc-thumb {
position: absolute; inset: 2px auto 2px 2px;
height: calc(100% - 4px);
background: white; border-radius: calc(var(--sc-radius) - 2px);
box-shadow: 0 1px 0 rgba(0,0,0,.05), 0 0 0 1px rgba(0,0,0,.02);
transition: transform .25s cubic-bezier(.2,.8,.2,1), width .25s cubic-bezier(.2,.8,.2,1);
will-change: transform, width;
z-index: 0; /* labels should have z-index:1 */
}
.sc-label { position: relative; z-index: 1; background: transparent !important; }
This keeps radios for semantics while adding a purely visual effect.
RTL, overflow, and long labels
- RTL: The radio semantics are direction-agnostic. Your pill layout should naturally mirror in RTL; verify the thumb logic by using logical properties or recalculating left offsets when
dir="rtl". - Overflow: If labels can overflow, allow the control to scroll horizontally:
overflow-x: auto; white-space: nowrap;on.sc-root. Ensure the focus ring remains visible. - Long labels: Use responsive min-widths and let items grow; consider tooltips for truncated content.
Theming and variants
- Size: Map sizes to padding/font-size via a
sizeprop and utility classes. - Color: Expose
--sc-accent,--sc-bg, and text variables for easy theme switching. - Density: Provide
compactvariants with smaller padding.
Example with inline theme override:
<div style={{
['--sc-accent' as any]: 'hsl(280 80% 55%)',
['--sc-bg' as any]: 'hsl(280 20% 96%)'
}}>
<SegmentedControl /* ... */ />
</div>
Testing with React Testing Library
A minimal test verifies rendering, selection changes, and keyboard navigation.
// SegmentedControl.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SegmentedControl } from './SegmentedControl';
test('changes value on click and arrow keys', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<SegmentedControl
ariaLabel="View"
options={[
{ label: 'List', value: 'list' },
{ label: 'Grid', value: 'grid' },
{ label: 'Compact', value: 'compact' },
]}
defaultValue="list"
onValueChange={onChange}
/>
);
// Click to change
await user.click(screen.getByLabelText('Grid'));
expect(onChange).toHaveBeenLastCalledWith('grid');
// Arrow key navigation on radios
const radio = screen.getByLabelText('Grid');
radio.focus();
await user.keyboard('{ArrowRight}'); // to Compact
expect(onChange).toHaveBeenLastCalledWith('compact');
});
Note: Rely on label text for queries; keep labels human-readable.
Performance considerations
- Radios are cheap; mapping 2–5 options is trivial.
- Avoid recalculating measurements on every render; only do it when the selection or layout changes (for the optional thumb).
- Debounce resize observers if you add them for the thumb.
Common pitfalls checklist
- Don’t remove radios from the accessibility tree (no
display: none). - Provide an accessible name via
legend,aria-label, oraria-labelledby. - Keep the selected state in sync when controlled.
- Ensure adequate color contrast in both selected and unselected states.
- Verify focus-visible styles and keyboard interaction.
- Test in RTL and with long labels.
Final thoughts
By building on top of native radios, you get correct semantics, keyboard support, and form integration for free. A thin React wrapper then provides a clean API for controlled/uncontrolled usage, theming, and extensibility. From there, you can layer in animations like a sliding thumb without sacrificing accessibility.
Use this pattern as a template for other “button-like” toggles that are fundamentally single-choice: start with the right semantic primitive, then progressively enhance the visuals and developer experience.
Related Posts
Building an Accessible, Animated Expandable Card in React
Build an accessible, animated React expandable card with TypeScript. Covers ARIA, controlled/uncontrolled state, CSS and Framer Motion, and testing.
Build an accessible React password show or hide toggle
Build an accessible React password field with a show or hide toggle, covering UX, a11y, TypeScript, SSR, and integrations with MUI and Chakra.
Building an Accessible React Split Button Dropdown in React + TypeScript
Build an accessible React split button dropdown with ARIA, keyboard support, and TypeScript—complete code, styling, and testing tips.