Tech Store
← UI Components

Segmented Toggle

Free

A two-to-four option switch with a sliding thumb, built on native radio buttons.

0 px
100%

Code

SegmentedToggle.tsx
import { useId, useLayoutEffect, useRef, useState } from "react";

export interface SegmentedToggleProps {
  label: string;
  options: readonly string[];
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  name?: string;
}

interface Thumb {
  left: number;
  width: number;
}

export default function SegmentedToggle({ label, options, value, defaultValue, onChange, name }: SegmentedToggleProps) {
  const fallbackName = useId();
  const [internal, setInternal] = useState(defaultValue ?? options[0] ?? "");
  const selected = value ?? internal;
  const listRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<Thumb>({ left: 0, width: 0 });

  useLayoutEffect(() => {
    const active = listRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(selected)}"]`);
    if (active) setThumb({ left: active.offsetLeft, width: active.offsetWidth });
  }, [selected, options]);

  const choose = (next: string) => {
    if (value === undefined) setInternal(next);
    onChange?.(next);
  };

  return (
    <fieldset className="inline-flex flex-col gap-2">
      <legend className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">{label}</legend>
      <div ref={listRef} className="relative inline-flex rounded-full bg-zinc-100 p-1 dark:bg-zinc-800">
        <span
          aria-hidden="true"
          className="absolute inset-y-1 rounded-full bg-white shadow-sm transition-[left,width] duration-300 ease-out motion-reduce:transition-none dark:bg-zinc-950"
          style={{ left: thumb.left, width: thumb.width }}
        />
        {options.map((option) => (
          <label
            key={option}
            data-value={option}
            className="relative z-10 cursor-pointer rounded-full px-4 py-1.5 text-sm font-medium text-zinc-500 transition-colors has-[:checked]:text-zinc-900 has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-blue-500 dark:text-zinc-400 dark:has-[:checked]:text-white"
          >
            <input
              type="radio"
              className="sr-only"
              name={name ?? fallbackName}
              value={option}
              checked={selected === option}
              onChange={() => choose(option)}
            />
            {option}
          </label>
        ))}
      </div>
    </fieldset>
  );
}

export function Preview() {
  return <SegmentedToggle label="Billing period" options={["Monthly", "Yearly", "Lifetime"]} defaultValue="Yearly" />;
}

About Segmented Toggle

For short, mutually exclusive choices — billing period, view mode, units — where a dropdown would hide the options.

Features

  • The thumb slides to the chosen option and resizes to its label.
  • Built on real radio inputs, so it submits with a form and works without JavaScript styling hooks.
  • Controlled or uncontrolled in React.

Accessibility

  • A radiogroup with a visible label; arrow keys move between options as with any radio group.
  • Focus ring on the active option.
  • The slide is instant under prefers-reduced-motion: reduce.

More components

View all →