# Segmented control (Caseroom component)

Native radios drawn as one pill track, like a billing-period toggle. One Tab stop, arrows choose. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/segmented.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/segmented/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### segmented.astro

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

interface Props {
  name: string;
  legend: string;
  options: SegmentedOption[];
  value?: string;
  id?: string;
  class?: string;
}
const { name, legend, options, value, id = name, class: className } = Astro.props;
---

<fieldset class={cn(c.root, className)}>
  <legend class={c.legend}>{legend}</legend>
  <div class={c.track}>
    {
      options.map((o) => (
        <div class={c.option}>
          <input type="radio" id={`${id}-${o.value}`} name={name} value={o.value} checked={o.value === value} disabled={o.disabled} aria-label={o.note ? `${o.label}, ${o.note}` : undefined} class={c.input} />
          <label for={`${id}-${o.value}`} class={c.label}>
            {o.label}
            {o.note && <span class={c.note} aria-hidden="true">{o.note}</span>}
          </label>
        </div>
      ))
    }
  </div>
</fieldset>
```

### segmented.tsx

```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

```tsx
/** 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 };
```
