GitHub

ButtonGroup

A set of buttons visually joined together, used for segmented actions or view toggles. Supports horizontal and vertical orientations with shared variant and size propagation.

AccessibleDark Mode2 Variants3 Sizes2 Orientations

Install

$npx react-principles add button-group
01

Theme Preview

Both variants across light and dark themes — forced styling for accurate side-by-side comparison.

Light
LeftCenterRight
SaveCancel
Dark
LeftCenterRight
SaveCancel
02

Live Demo

Variant
Size
Orientation
03

Code Snippet

src/ui/ButtonGroup.tsx
import { ButtonGroup } from "@/ui/ButtonGroup";
import { Button } from "@/ui/Button";

// Horizontal (default)
<ButtonGroup variant="default">
  <Button>Left</Button>
  <Button>Center</Button>
  <Button>Right</Button>
</ButtonGroup>

// Outline variant
<ButtonGroup variant="outline">
  <Button>Save</Button>
  <Button>Cancel</Button>
</ButtonGroup>

// Vertical orientation
<ButtonGroup orientation="vertical">
  <Button>Top</Button>
  <Button>Middle</Button>
  <Button>Bottom</Button>
</ButtonGroup>

// Sizes
<ButtonGroup size="sm">...</ButtonGroup>
<ButtonGroup size="md">...</ButtonGroup>
<ButtonGroup size="lg">...</ButtonGroup>
04

Copy-Paste (Single File)

ButtonGroup.tsx
import {
  Children,
  cloneElement,
  isValidElement,
  type HTMLAttributes,
  type ReactElement,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import type { ButtonProps } from "./Button";

export type ButtonGroupOrientation = "horizontal" | "vertical";
export type ButtonGroupVariant = "default" | "outline";
export type ButtonGroupSize = "sm" | "md" | "lg";

export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
  orientation?: ButtonGroupOrientation;
  variant?: ButtonGroupVariant;
  size?: ButtonGroupSize;
  disabled?: boolean;
  children: ReactNode;
}

const VARIANT_MAP: Record<ButtonGroupVariant, ButtonProps["variant"]> = {
  default: "primary",
  outline: "outline",
};

function getGroupedRadiusClass(
  index: number,
  total: number,
  isVertical: boolean,
): string {
  if (total <= 1) return "";
  const isFirst = index === 0;
  const isLast = index === total - 1;

  if (isVertical) {
    if (isFirst) return "rounded-b-none";
    if (isLast) return "rounded-t-none";
    return "rounded-none";
  }

  if (isFirst) return "rounded-r-none";
  if (isLast) return "rounded-l-none";
  return "rounded-none";
}

export function ButtonGroup({
  orientation = "horizontal",
  variant = "default",
  size = "md",
  disabled = false,
  children,
  className,
  ...props
}: ButtonGroupProps) {
  const isVertical = orientation === "vertical";
  const mappedVariant = VARIANT_MAP[variant];
  const count = Children.count(children);

  return (
    <div
      role="group"
      className={cn(
        isVertical ? "inline-flex flex-col" : "inline-flex items-center",
        className,
      )}
      {...props}
    >
      {Children.map(children, (child, index) => {
        if (!isValidElement(child)) return child;
        const childProps = child.props as Partial<ButtonProps>;
        const radiusClass = getGroupedRadiusClass(index, count, isVertical);
        const spacingClass = isVertical
          ? "not-first:-mt-px"
          : "not-first:-ml-px";

        return cloneElement(child as ReactElement<ButtonProps>, {
          variant: childProps.variant ?? mappedVariant,
          size: childProps.size ?? size,
          disabled: childProps.disabled ?? disabled,
          className: cn(radiusClass, spacingClass, childProps.className),
        });
      })}
    </div>
  );
}
05

Props

Extends all native HTMLDivElement attributes (id, aria-label, etc.).

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Layout direction of the button group.
variant"default" | "outline""default"Variant applied to all child Buttons. "default" maps to primary, "outline" maps to outline.
size"sm" | "md" | "lg""md"Size applied to all child Buttons.
disabledbooleanfalseDisables all child Buttons.
childrenReactNodeMust contain <Button> elements.
classNamestringExtra CSS classes on the container div.
React Principles