Skip to content
    Style: Wireframe

    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,073 sites in The UI Index have a tabs (265 sites).

    Used in UI Pro: Pricing

    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/ui/r/segmented.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/segmented.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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 };