React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

toggle-groupsegmented-controlbutton-groupselectioncomponenttailwind

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.

Use Cases

  • Switch between grid and list views in a data display
  • Select a time range filter (day, week, month, year)
  • Choose a text alignment option (left, center, right, justify)
  • Pick a shipping method or plan tier
  • Toggle formatting options (bold, italic, underline) in a rich text toolbar
  • Select a category or tag filter in search results
  • Switch between tabs or modes in a compact toolbar

Simplest Implementation

"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.

Variations

Single Select with Typed Options

"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.

Multi Select

"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).

With Icons

"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.

Pill Style

"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.

Outline Style

"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.

Sizes (sm / md / lg)

"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.

Complex Implementation

"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:

  • Discriminated union props -- the 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.
  • Roving tabindex -- in single-select mode, only the selected button has tabIndex={0}. Arrow keys move focus and selection together, matching the WAI-ARIA radio group pattern. In multi-select mode, all buttons are tabbable.
  • Arrow key navigation -- the keyboard handler supports ArrowRight/Left, ArrowUp/Down, Home, and End keys. Disabled options are skipped in the navigation cycle. In single mode, focus movement also selects the option.
  • Variant system -- three visual variants (default, pill, outline) are defined as class maps, keeping the rendering logic clean. Each variant specifies its own container, active, inactive, and border-radius classes.
  • Per-option disabled -- individual options can be disabled independently from the entire group. Disabled options are excluded from keyboard navigation and have cursor-not-allowed styling.
  • forwardRef on the group container -- allows parent components to measure the group width for responsive layouts or scroll the group into view.
  • aria-labelledby -- when a visible 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.
  • focus-visible ring -- the focus ring appears only during keyboard navigation, keeping the UI clean for mouse users while maintaining accessibility.

Gotchas

  • 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.

  • Button -- Individual button component used as the base element
  • Tabs -- Navigation pattern that uses a similar segmented layout
  • Switch -- Binary toggle for a single on/off choice
  • Forms -- Form patterns for managing group selection state