Toggle Group
A segmented control where one or more options can be selected from a row of buttons, commonly used for view switchers, filters, and compact option pickers.
Search across all documentation pages
A segmented control where one or more options can be selected from a row of buttons, commonly used for view switchers, filters, and compact option pickers.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}A minimal single-select toggle group using role="radiogroup" and role="radio" for accessibility. The selected button gets a blue background while unselected buttons remain white. The first:rounded-l-lg and last:rounded-r-lg utilities round only the outer corners, creating a connected pill shape.
"use client";
interface ToggleOption {
value: string;
label: string;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.label}
</button>
))}
</div>
);
}Separating value from label allows display text to differ from the programmatic value. This is common when values are enum-like strings ("grid", "list") but labels need to be more descriptive or localized.
"use client";
interface ToggleGroupProps {
options: string[];
values: string[];
onChange: (values: string[]) => void;
}
export function ToggleGroup({ options, values, onChange }: ToggleGroupProps) {
function toggle(option: string) {
if (values.includes(option)) {
onChange(values.filter((v) => v !== option));
} else {
onChange([...values, option]);
}
}
return (
<div className="inline-flex rounded-lg border border-gray-300" role="group" aria-label="Toggle options">
{options.map((option) => (
<button
key={option}
role="checkbox"
aria-checked={values.includes(option)}
onClick={() => toggle(option)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
values.includes(option)
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Multi-select uses role="checkbox" instead of role="radio" because multiple options can be active simultaneously. The toggle function adds or removes the clicked option from the values array. This pattern is common for text formatting toolbars (bold, italic, underline).
"use client";
interface ToggleOption {
value: string;
label: string;
icon: React.ReactNode;
}
interface ToggleGroupProps {
options: ToggleOption[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={value === option.value}
aria-label={option.label}
onClick={() => onChange(option.value)}
className={`inline-flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option.icon}
<span className="hidden sm:inline">{option.label}</span>
</button>
))}
</div>
);
}
// Usage:
// <ToggleGroup
// value={view}
// onChange={setView}
// options={[
// {
// value: "grid",
// label: "Grid",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" /></svg>,
// },
// {
// value: "list",
// label: "List",
// icon: <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>,
// },
// ]}
// />Icons are displayed alongside labels, with labels hidden on small screens via hidden sm:inline to save space. The aria-label ensures accessibility when the label text is not visible. This is the standard pattern for view-switcher controls.
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-1 rounded-full bg-gray-100 p-1" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-all ${
value === option
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{option}
</button>
))}
</div>
);
}A pill-shaped segmented control with a background tray. The selected option gets a white background with a subtle shadow, creating a raised "tab" effect. The gap-1 and p-1 on the container provide even internal spacing. This style is popular in iOS-inspired and modern dashboard interfaces.
"use client";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
}
export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) {
return (
<div className="inline-flex gap-2" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`rounded-lg border-2 px-4 py-2 text-sm font-medium transition-colors ${
value === option
? "border-blue-600 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}Each option is a standalone bordered button with spacing between them rather than a connected strip. The selected button gets a blue border and tinted background. This style works well when options are visually heavy (e.g., cards with descriptions) and need more separation.
"use client";
type GroupSize = "sm" | "md" | "lg";
interface ToggleGroupProps {
options: string[];
value: string;
onChange: (value: string) => void;
size?: GroupSize;
}
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
export function ToggleGroup({ options, value, onChange, size = "md" }: ToggleGroupProps) {
return (
<div className="inline-flex rounded-lg border border-gray-300" role="radiogroup">
{options.map((option) => (
<button
key={option}
role="radio"
aria-checked={value === option}
onClick={() => onChange(option)}
className={`font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${sizeClasses[size]} ${
value === option
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{option}
</button>
))}
</div>
);
}A size map controls padding and font size together to keep proportions consistent at each size. Small works well in dense toolbars, medium is the default for forms, and large suits hero sections or prominent filters.
"use client";
import { forwardRef, useId, useCallback, useRef } from "react";
type SelectionMode = "single" | "multiple";
type GroupSize = "sm" | "md" | "lg";
type GroupVariant = "default" | "pill" | "outline";
interface ToggleOption {
value: string;
label: string;
icon?: React.ReactNode;
disabled?: boolean;
}
interface ToggleGroupBaseProps {
options: ToggleOption[];
size?: GroupSize;
variant?: GroupVariant;
disabled?: boolean;
label?: string;
className?: string;
}
interface SingleToggleGroupProps extends ToggleGroupBaseProps {
mode?: "single";
value: string;
onChange: (value: string) => void;
}
interface MultipleToggleGroupProps extends ToggleGroupBaseProps {
mode: "multiple";
value: string[];
onChange: (value: string[]) => void;
}
type ToggleGroupProps = SingleToggleGroupProps | MultipleToggleGroupProps;
const sizeClasses: Record<GroupSize, string> = {
sm: "px-2.5 py-1 text-xs gap-1.5",
md: "px-4 py-2 text-sm gap-2",
lg: "px-6 py-3 text-base gap-2.5",
};
const variantClasses: Record<GroupVariant, { container: string; active: string; inactive: string; rounded: string }> = {
default: {
container: "inline-flex rounded-lg border border-gray-300",
active: "bg-blue-600 text-white",
inactive: "bg-white text-gray-700 hover:bg-gray-50",
rounded: "first:rounded-l-lg last:rounded-r-lg",
},
pill: {
container: "inline-flex gap-1 rounded-full bg-gray-100 p-1",
active: "bg-white text-gray-900 shadow-sm",
inactive: "text-gray-600 hover:text-gray-900",
rounded: "rounded-full",
},
outline: {
container: "inline-flex gap-2",
active: "border-blue-600 bg-blue-50 text-blue-700",
inactive: "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50",
rounded: "rounded-lg border-2",
},
};
export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(function ToggleGroup(
props,
ref
) {
const {
options,
size = "md",
variant = "default",
disabled = false,
label,
className,
mode = "single",
value,
onChange,
} = props;
const groupId = useId();
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const v = variantClasses[variant];
const isSelected = useCallback(
(optionValue: string) => {
if (mode === "multiple") {
return (value as string[]).includes(optionValue);
}
return value === optionValue;
},
[mode, value]
);
const handleClick = useCallback(
(optionValue: string) => {
if (disabled) return;
if (mode === "multiple") {
const currentValues = value as string[];
const multiOnChange = onChange as (v: string[]) => void;
if (currentValues.includes(optionValue)) {
multiOnChange(currentValues.filter((v) => v !== optionValue));
} else {
multiOnChange([...currentValues, optionValue]);
}
} else {
(onChange as (v: string) => void)(optionValue);
}
},
[disabled, mode, value, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
const enabledIndices = options
.map((opt, i) => (!opt.disabled ? i : -1))
.filter((i) => i !== -1);
const currentEnabledIndex = enabledIndices.indexOf(index);
let nextIndex: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex + 1) % enabledIndices.length];
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
nextIndex = enabledIndices[(currentEnabledIndex - 1 + enabledIndices.length) % enabledIndices.length];
break;
case "Home":
e.preventDefault();
nextIndex = enabledIndices[0];
break;
case "End":
e.preventDefault();
nextIndex = enabledIndices[enabledIndices.length - 1];
break;
}
if (nextIndex !== null) {
buttonRefs.current[nextIndex]?.focus();
if (mode === "single") {
handleClick(options[nextIndex].value);
}
}
},
[options, mode, handleClick]
);
const role = mode === "single" ? "radiogroup" : "group";
const itemRole = mode === "single" ? "radio" : "checkbox";
return (
<div className={className}>
{label && (
<span id={`${groupId}-label`} className="mb-2 block text-sm font-medium text-gray-700">
{label}
</span>
)}
<div
ref={ref}
role={role}
aria-labelledby={label ? `${groupId}-label` : undefined}
aria-label={label ? undefined : "Toggle group"}
className={`${v.container} ${disabled ? "opacity-50" : ""}`}
>
{options.map((option, index) => {
const selected = isSelected(option.value);
const isDisabled = disabled || !!option.disabled;
return (
<button
key={option.value}
ref={(el) => { buttonRefs.current[index] = el; }}
role={itemRole}
type="button"
aria-checked={selected}
aria-label={option.icon && !option.label ? option.value : undefined}
disabled={isDisabled}
tabIndex={
mode === "single"
? selected || (value === "" && index === 0) ? 0 : -1
: 0
}
onClick={() => handleClick(option.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={[
"inline-flex items-center font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1",
sizeClasses[size],
v.rounded,
isDisabled ? "cursor-not-allowed" : "cursor-pointer",
selected ? v.active : v.inactive,
].join(" ")}
>
{option.icon && <span className="shrink-0">{option.icon}</span>}
{option.label && <span>{option.label}</span>}
</button>
);
})}
</div>
</div>
);
});Key aspects:
mode prop switches between SingleToggleGroupProps and MultipleToggleGroupProps, providing correct TypeScript types for value and onChange at the call site. Single mode uses a string, multiple mode uses a string array.tabIndex={0}. Arrow keys move focus and selection together, matching the WAI-ARIA radio group pattern. In multi-select mode, all buttons are tabbable.cursor-not-allowed styling.label prop is provided, the group references it via aria-labelledby so screen readers announce the group's purpose. Without a label, a generic aria-label is used as a fallback.Using role="radiogroup" for multi-select -- radio groups only allow one selection. If multiple options can be active, use role="group" with role="checkbox" on each button instead of role="radio".
Missing type="button" inside forms -- buttons inside a <form> default to type="submit". Every toggle button needs type="button" to prevent form submission on click.
Tailwind first: and last: not applying -- if the buttons are wrapped in extra elements (like a <div> for tooltips), the first: and last: pseudo-classes target the wrappers, not the buttons. Apply border-radius to the wrapper instead.
Keyboard navigation without roving tabindex -- if every button has tabIndex={0}, the user must press Tab through every option to leave the group. Use roving tabindex (tabIndex={-1} on non-selected items) so Tab moves past the entire group in one keystroke.
Array reference equality in multi-select -- passing a new array reference on every render (e.g., values={[...selected]}) can cause unnecessary re-renders in child components. Memoize the values array or use a stable reference.
Selected state lost on remount -- if the toggle group is conditionally rendered (e.g., inside a tab panel), the selection resets unless state is lifted to a parent or persisted. Always store the value in the closest stable ancestor.
Inconsistent gap between connected buttons -- borders on adjacent buttons double up, creating a 2px line between them. Use border-l-0 on all but the first button, or use a single border on the container with divide-x utility.
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥