Skip to content
Caseroom

Components · Forms

Segmented control

Native radios drawn as one pill track, like a billing-period toggle. One Tab stop, arrows choose.

13% of the 2,062 sites in The UI Index have a tabs (264 sites).

Billing period
Range

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/segmented.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/segmented.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.

segmented.tsx
import { cn } from '@/lib/utils';
import { segmented as c, type SegmentedOption } from './segmented.variants';

export type { SegmentedOption };

export interface SegmentedProps {
  name: string;
  legend: string;
  options: SegmentedOption[];
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  id?: string;
  className?: string;
}

/** Native radios drawn as one pill track: one Tab stop, arrows choose, no JavaScript needed. */
export function Segmented({ name, legend, options, defaultValue, onValueChange, id = name, className }: SegmentedProps) {
  return (
    <fieldset className={cn(c.root, className)}>
      <legend className={c.legend}>{legend}</legend>
      <div className={c.track}>
        {options.map((o) => (
          <div key={o.value} className={c.option}>
            <input type="radio" id={`${id}-${o.value}`} name={name} value={o.value} defaultChecked={o.value === defaultValue} disabled={o.disabled} aria-label={o.note ? `${o.label}, ${o.note}` : undefined} className={c.input} onChange={() => onValueChange?.(o.value)} />
            <label htmlFor={`${id}-${o.value}`} className={c.label}>
              {o.label}
              {o.note && <span className={c.note} aria-hidden="true">{o.note}</span>}
            </label>
          </div>
        ))}
      </div>
    </fieldset>
  );
}
segmented.variants.ts
/** A segmented control (the "billing period toggle"): native radios in a fieldset drawn as one pill track, the
 *  chosen one raised. One Tab stop, arrow keys choose (the browser's radio group), no JavaScript. An optional note
 *  per option ("save 20%"). */
export const segmented = {
  root: 'm-0 inline-flex min-w-0 border-0 p-0',
  legend: 'sr-only',
  track: 'inline-flex items-center gap-1 rounded-lg bg-muted p-1',
  option: 'relative',
  input: 'peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md outline-offset-2 disabled:cursor-not-allowed',
  // The chosen option keeps its raised white ground under the pointer (a grey fill would read as another option's
  // hover); hover darkens its edge, press darkens the ground a step and presses it in.
  label:
    'pointer-events-none flex h-8 items-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[background-color,color,box-shadow,transform] peer-[:enabled:hover]:bg-input/40 peer-[:enabled:hover]:text-foreground peer-[:enabled:active]:scale-[0.97] peer-[:enabled:active]:bg-pressed peer-checked:bg-background peer-checked:text-foreground peer-checked:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] peer-checked:peer-[:enabled:hover]:shadow-[0_1px_3px_rgb(0_0_0/0.16),0_0_0_1px_color-mix(in_oklab,var(--foreground)_45%,transparent)] peer-checked:peer-[:enabled:hover]:bg-background peer-checked:peer-[:enabled:hover:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-checked:peer-[:enabled:active]:bg-[color-mix(in_oklab,var(--background)_90%,var(--foreground))] peer-disabled:opacity-50',
  note: 'rounded-sm bg-foreground px-1 py-px text-[11px] text-background',
};
export type SegmentedOption = { value: string; label: string; note?: string; disabled?: boolean };