Button Group
Buttons

Button Group

Attached segmented buttons with single and multiple select modes, roving tabindex, and a selection tint that slides between segments.

Install

npx shadcn@latest add @paragon/button-group

button-group.tsx

"use client";

import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

interface ButtonGroupContextValue {
  type: "single" | "multiple";
  value: string;
  layoutId: string;
  orientation: "horizontal" | "vertical";
  isStatic: boolean;
}

const ButtonGroupContext = React.createContext<ButtonGroupContextValue | null>(
  null,
);

function useButtonGroup() {
  const context = React.useContext(ButtonGroupContext);
  if (!context) {
    throw new Error("ButtonGroupItem must be used within a ButtonGroup");
  }
  return context;
}

interface ButtonGroupBaseProps
  extends Omit<React.ComponentProps<"div">, "defaultValue" | "dir"> {
  orientation?: "horizontal" | "vertical";
  disabled?: boolean;
  /** Disables the sliding tint; selection snaps instantly. */
  static?: boolean;
}

interface ButtonGroupSingleProps extends ButtonGroupBaseProps {
  type?: "single";
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
}

interface ButtonGroupMultipleProps extends ButtonGroupBaseProps {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
}

export type ButtonGroupProps =
  | ButtonGroupSingleProps
  | ButtonGroupMultipleProps;

/**
 * Segmented, attached buttons on a single card surface with hairline
 * dividers. Single mode slides one selection tint between segments
 * (interruptible shared-element spring); multiple mode fades a tint per
 * pressed segment. One tab stop — Radix's roving tabindex moves focus
 * with the arrow keys.
 */
export function ButtonGroup(props: ButtonGroupProps) {
  const {
    type = "single",
    orientation = "horizontal",
    static: isStatic = false,
    className,
    children,
    disabled,
    value: _value,
    defaultValue: _defaultValue,
    onValueChange: _onValueChange,
    ...rest
  } = props;
  const layoutId = React.useId();

  // Mirror of the current single-mode value so the sliding tint knows its
  // segment even when Radix owns the (uncontrolled) state.
  const [internalValue, setInternalValue] = React.useState(
    props.type !== "multiple" && typeof props.defaultValue === "string"
      ? props.defaultValue
      : "",
  );
  const singleValue =
    props.type !== "multiple" && typeof props.value === "string"
      ? props.value
      : internalValue;

  const contextValue = React.useMemo<ButtonGroupContextValue>(
    () => ({
      type,
      value: type === "single" ? singleValue : "",
      layoutId,
      orientation,
      isStatic,
    }),
    [type, singleValue, layoutId, orientation, isStatic],
  );

  const rootClassName = cn(
    "inline-flex w-fit items-stretch rounded-lg bg-card shadow-border",
    orientation === "vertical"
      ? "flex-col divide-y divide-border"
      : "divide-x divide-border",
    className,
  );

  const content = (
    <ButtonGroupContext.Provider value={contextValue}>
      {children}
    </ButtonGroupContext.Provider>
  );

  if (props.type === "multiple") {
    return (
      <ToggleGroupPrimitive.Root
        type="multiple"
        data-slot="button-group"
        orientation={orientation}
        disabled={disabled}
        value={props.value}
        defaultValue={props.defaultValue}
        onValueChange={props.onValueChange}
        className={rootClassName}
        {...rest}
      >
        {content}
      </ToggleGroupPrimitive.Root>
    );
  }

  return (
    <ToggleGroupPrimitive.Root
      type="single"
      data-slot="button-group"
      orientation={orientation}
      disabled={disabled}
      value={props.value}
      defaultValue={props.defaultValue}
      onValueChange={(next: string) => {
        setInternalValue(next);
        props.onValueChange?.(next);
      }}
      className={rootClassName}
      {...rest}
    >
      {content}
    </ToggleGroupPrimitive.Root>
  );
}

export interface ButtonGroupItemProps
  extends React.ComponentProps<typeof ToggleGroupPrimitive.Item> {
  value: string;
}

export function ButtonGroupItem({
  className,
  children,
  value,
  ...props
}: ButtonGroupItemProps) {
  const {
    type,
    value: activeValue,
    layoutId,
    orientation,
    isStatic,
  } = useButtonGroup();
  const reducedMotion = useReducedMotion();
  const showSlidingTint = type === "single" && activeValue === value;

  return (
    <ToggleGroupPrimitive.Item
      data-slot="button-group-item"
      value={value}
      className={cn(
        "group/bgi relative inline-flex h-9 items-center justify-center px-3 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none select-none",
        "transition-[color,background-color] duration-150 ease-out",
        // Vertical hit-area extension to 40px; segments sit flush sideways.
        "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
        "hover:text-foreground focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
        "disabled:pointer-events-none disabled:opacity-50",
        "data-[state=on]:text-foreground",
        orientation === "vertical"
          ? "first:rounded-t-[inherit] last:rounded-b-[inherit]"
          : "first:rounded-l-[inherit] last:rounded-r-[inherit]",
        // Multiple mode: each pressed segment tints in place.
        type === "multiple" && "data-[state=on]:bg-accent",
        "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      {...props}
    >
      {showSlidingTint && (
        <motion.span
          layoutId={layoutId}
          aria-hidden
          initial={false}
          transition={
            isStatic || reducedMotion
              ? { duration: 0 }
              : { type: "spring", duration: 0.35, bounce: 0 }
          }
          className="pointer-events-none absolute inset-0 rounded-[inherit] bg-accent"
        />
      )}
      {/* Press feedback on the content only, so attached seams never wobble. */}
      <span
        className={cn(
          "relative z-10 inline-flex items-center gap-2",
          !isStatic &&
            "transition-[scale] duration-150 ease-out group-active/bgi:scale-[0.97]",
        )}
      >
        {children}
      </span>
    </ToggleGroupPrimitive.Item>
  );
}