Component

Toggle Group

A joined set of toggles. `type="single"` behaves like a radiogroup (one on); `type="multiple"` like a group of checkboxes. Selected segments use `surface-active` + `aria-pressed`, with arrow-key roving.

  • Component
  • React
  • TSX
  • Tailwind CSS
  • MIT

Preview

Live component

Interactive preview
Open preview
9:41 100%
devsnips.dev/library/react/components/buttons/toggle-group/
fluid · no fixed width 100%
No dependencies Responsive · Light/dark · Focus-visible

Install

Add to your project

terminal
npx devsnips add React/Components/Buttons/toggle-group
registry path: React/Components/Buttons/toggle-group

Source

Implementation

code.tsx
import { useRef, useState } from "react";
import type { HTMLAttributes, ReactNode, KeyboardEvent } from "react";

/* DevSnips React — ToggleGroup
 * Joined toggles, single or multi select. Selected segments use
 * surface-active + aria-pressed. Arrow keys rove focus.
 */

export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl";
export type ToggleGroupType = "single" | "multiple";

export interface ToggleOption {
  value: string;
  label: ReactNode;
  icon?: string;
  disabled?: boolean;
}

export interface ToggleGroupProps extends HTMLAttributes<HTMLDivElement> {
  options: ToggleOption[];
  type?: ToggleGroupType;
  value?: string | string[];
  defaultValue?: string | string[];
  onValueChange?: (value: string | null | string[]) => void;
  size?: ButtonSize;
  label?: string;
}

function cx(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

const SIZES: Record<ButtonSize, string> = {
  xs: "h-7 gap-1 px-2 text-xs [&_svg]:size-[14px]",
  sm: "h-8 gap-1.5 px-3 text-xs [&_svg]:size-[14px]",
  md: "h-9 gap-2 px-3.5 text-[13px] [&_svg]:size-4",
  lg: "h-10 gap-2 px-4 text-[13px] [&_svg]:size-[18px]",
  xl: "h-11 gap-2 px-5 text-sm [&_svg]:size-5",
};

const SEG_BASE =
  "inline-flex items-center justify-center gap-2 border-0 bg-transparent px-3 font-medium leading-none " +
  "transition-colors duration-150 ease-out motion-reduce:transition-none " +
  "focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] " +
  "disabled:pointer-events-none disabled:opacity-50";

export function ToggleGroup({
  options,
  type = "single",
  value,
  defaultValue,
  onValueChange,
  size = "sm",
  label,
  className,
  ...rest
}: ToggleGroupProps) {
  const initialArr = defaultValue
    ? (Array.isArray(defaultValue) ? defaultValue : [defaultValue])
    : [];
  const [internal, setInternal] = useState<string[]>(initialArr);
  const isControlled = value !== undefined;
  const ctrlArr = Array.isArray(value) ? value : value ? [value] : [];
  const current = isControlled ? ctrlArr : internal;
  const refs = useRef<Array<HTMLButtonElement | null>>([]);

  function isActive(v: string) { return current.indexOf(v) !== -1; }
  function toggle(v: string) {
    let next: string[];
    if (type === "single") next = isActive(v) ? [] : [v];
    else next = isActive(v) ? current.filter((x) => x !== v) : [...current, v];
    if (!isControlled) setInternal(next);
    onValueChange?.(type === "single" ? next[0] ?? null : next);
  }
  function onKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const n = options.length;
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % n;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + n) % n;
    if (next >= 0) { e.preventDefault(); refs.current[next]?.focus(); }
  }

  return (
    <div role="group" aria-label={label} className={cx("inline-flex overflow-hidden rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border-strong)]", className)} {...rest}>
      {options.map((opt, i) => {
        const on = isActive(opt.value);
        return (
          <button
            key={opt.value}
            ref={(el) => { refs.current[i] = el; }}
            type="button"
            aria-pressed={on}
            disabled={opt.disabled}
            onClick={() => toggle(opt.value)}
            onKeyDown={(e) => onKey(e, i)}
            className={cx(
              SEG_BASE,
              SIZES[size],
              "rounded-none",
              i > 0 && "-ml-px border-l border-[var(--ds-color-border)]",
              on ? "bg-[var(--ds-color-surface-active)] font-semibold" : "hover:bg-[var(--ds-color-surface-hover)]",
            )}
          >
            {opt.icon ? <Icon name={opt.icon} className="shrink-0" /> : null}
            <span>{opt.label}</span>
          </button>
        );
      })}
    </div>
  );
}

export default ToggleGroup;
113 lines UTF-8 · LF · Spaces: 2

AI-ready

Available to your agent.

This component is reachable through the DevSnips CLI, MCP server and Skills integrations — one registry, one resource path.

Resource path

devsnips://components/buttons/toggle-group

Continue browsing

View all