Segmented Toggle
FreeA two-to-four option switch with a sliding thumb, built on native radio buttons.
- #toggle
- #switch
- #radio
- #animation
- #form
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
radiogroupwith 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.