Component

Segmented Button

A joined single-choice control that behaves like a radiogroup: one selected option at a time. Use for 2–5 mutually exclusive options in compact toolbars.

  • Component
  • React
  • TSX
  • Tailwind CSS
  • MIT

Preview

Live component

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

Install

Add to your project

terminal
npx devsnips add React/Components/Buttons/segmented-button
registry path: React/Components/Buttons/segmented-button

Source

Implementation

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

/* DevSnips React — SegmentedButton
 * Joined single-choice control (radiogroup semantics). One selected option
 * at a time. ArrowLeft/Right rove selection and focus.
 */

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

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

export interface SegmentedButtonProps extends HTMLAttributes<HTMLDivElement> {
  options: SegmentedOption[];
  value: string;
  onChange: (value: 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 SegmentedButton({
  options,
  value,
  onChange,
  size = "sm",
  label,
  className,
  ...rest
}: SegmentedButtonProps) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);
  const height = SIZES[size];

  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();
      const opt = options[next];
      if (!opt.disabled) {
        onChange(opt.value);
        refs.current[next]?.focus();
      }
    }
  }

  return (
    <div role="radiogroup" 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 selected = opt.value === value;
        return (
          <button
            key={opt.value}
            ref={(el) => { refs.current[i] = el; }}
            type="button"
            role="radio"
            aria-checked={selected}
            disabled={opt.disabled}
            onClick={() => onChange(opt.value)}
            onKeyDown={(e) => onKey(e, i)}
            className={cx(
              SEG_BASE,
              height,
              "rounded-none",
              i > 0 && "-ml-px border-l border-[var(--ds-color-border)]",
              selected ? "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 SegmentedButton;
102 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/segmented-button

Continue browsing

View all