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.

ASOasis
8 min read
Build an Accessible React Segmented Control in React and TypeScript

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 same name attribute; optionally group them in a <fieldset> with a <legend> (or use aria-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/or defaultValue (uncontrolled).
  • onValueChange(value) callback.
  • name for form integration (optional; generated if omitted).
  • size and className for styling.
  • ariaLabel or ariaLabelledBy for 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 onValueChange only 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 value and onValueChange to sync with React state.
  • Uncontrolled: Pass defaultValue; omit value.
  • HTML forms: Provide a name prop 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 size prop and utility classes.
  • Color: Expose --sc-accent, --sc-bg, and text variables for easy theme switching.
  • Density: Provide compact variants 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, or aria-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