Aura Design System

Button Group

A container that groups buttons (and optional text or separators) with shared styling and orientation.

Preview

Loading...
import {
  ChevronLeftIcon,
  DotsHorizontalIcon,
} from "@radix-ui/react-icons";
import {
  ButtonGroup,
  ButtonGroupSeparator,
  ButtonGroupText,
} from "@/components/ui/ButtonGroup";
import { Button } from "@/components/ui/Button";

export function ButtonGroupDemo() {
  return (
    <div className="flex flex-wrap gap-2">
      <ButtonGroup aria-label="Go back">
        <Button variant="pill" aria-label="Go back">
          <ChevronLeftIcon className="icon" />
        </Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Archive and report">
        <Button variant="pill">Archive</Button>
        <Button variant="pill">Report</Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Snooze">
        <Button variant="pill">Snooze</Button>
        <Button variant="pill" aria-label="More options">
          <DotsHorizontalIcon className="icon" />
        </Button>
      </ButtonGroup>
    </div>
  );
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/button-group

Manual

Install the following dependencies:

pnpm install @radix-ui/react-slot class-variance-authority

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the separator component into your components/ui/Separator.tsx file.

"use client"

import * as React from "react"
import {Separator as SeparatorPrimitive} from "radix-ui"

import { cn } from "@/utils/class-names"

function Separator({
  className,
  orientation = "horizontal",
  decorative = true,
  ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
  return (
    <SeparatorPrimitive.Root
      data-slot="separator"
      decorative={decorative}
      orientation={orientation}
      className={cn(
        "bg-gray-a6 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
        className
      )}
      {...props}
    />
  )
}

export { Separator }

Copy and paste the ButtonGroup component into your components/ui/ButtonGroup.tsx file.

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/utils/class-names"
import { Button, type ButtonProps } from "@/components/ui/Button"
import { Separator } from "@/components/ui/Separator"

/**
 * `default` maps child buttons to `md` / `icon-md` (52px).
 * `sm` maps them to `sm` / `icon-sm` (39px). `icon` is the same 39px control.
 *
 * Size is cloned onto direct `Button` and `ButtonGroupText` children.
 * `Button` stays free of `createContext`, which React's server build does not export.
 */
type ButtonGroupSize = "default" | "sm"

const buttonGroupVariants = cva(
  "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
  {
    variants: {
      orientation: {
        horizontal:
          "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
        vertical:
          "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
      },
    },
    defaultVariants: {
      orientation: "horizontal",
    },
  }
)

const groupSizeWarnings = new Set<string>()

function warnOnce(message: string) {
  if (process.env.NODE_ENV === "production") return
  if (groupSizeWarnings.has(message)) return
  groupSizeWarnings.add(message)
  console.warn(message)
}

function visibleContent(props: {
  label?: React.ReactNode
  children?: React.ReactNode
}) {
  const hasLabel = props.label != null && props.label !== false
  const hasChildren = props.children != null && props.children !== false
  if (hasLabel && hasChildren) return props.children
  return hasChildren ? props.children : props.label
}

function isIconOnlyContent(node: React.ReactNode): boolean {
  const items = React.Children.toArray(node).filter(
    (child) => child !== null && child !== undefined && child !== false && child !== "",
  )
  if (items.length === 0) return false
  return items.every((child) => {
    if (typeof child === "string" || typeof child === "number") return false
    if (!React.isValidElement(child)) return false
    if (typeof child.type === "string" && (child.type === "svg" || child.type === "img")) {
      return true
    }
    const className = (child.props as { className?: unknown }).className
    return typeof className === "string" && className.split(/\s+/).includes("icon")
  })
}

function applyGroupSize(
  children: React.ReactNode,
  size: ButtonGroupSize,
): React.ReactNode {
  return React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child

    if (child.type === React.Fragment) {
      const fragmentProps = child.props as { children?: React.ReactNode }
      return React.cloneElement(
        child,
        {},
        applyGroupSize(fragmentProps.children, size),
      )
    }

    if (child.type === ButtonGroupText) {
      const textProps = child.props as { size?: ButtonGroupSize }
      if (textProps.size != null) return child
      return React.cloneElement(child, { size })
    }

    if (child.type === Button) {
      const buttonProps = child.props as ButtonProps
      if (buttonProps.size != null) return child
      const iconOnly = isIconOnlyContent(
        buttonProps.asChild ? null : visibleContent(buttonProps),
      )
      const nextSize = iconOnly
        ? size === "sm"
          ? "icon-sm"
          : "icon-md"
        : size === "sm"
          ? "sm"
          : "md"
      if (iconOnly && !buttonProps["aria-label"] && !buttonProps["aria-labelledby"]) {
        warnOnce(
          "Button: an icon-only button in a ButtonGroup needs an aria-label.",
        )
      }
      return React.cloneElement(child, { size: nextSize })
    }

    return child
  })
}

function ButtonGroup({
  className,
  orientation = "horizontal",
  size = "default",
  children,
  ...props
}: React.ComponentProps<"div"> &
  VariantProps<typeof buttonGroupVariants> & {
    size?: ButtonGroupSize
  }) {
  return (
    <div
      role="group"
      data-slot="button-group"
      data-orientation={orientation}
      data-size={size}
      className={cn(buttonGroupVariants({ orientation }), className)}
      {...props}
    >
      {applyGroupSize(children, size)}
    </div>
  )
}

function ButtonGroupText({
  className,
  asChild = false,
  size,
  ...props
}: React.ComponentProps<"div"> & {
  asChild?: boolean
  size?: ButtonGroupSize
}) {
  const Comp = asChild ? Slot : "div"

  return (
    <Comp
      className={cn(
        "flex items-center gap-0.5 rounded-sm border border-gray-6 bg-gray-2 px-2 font-medium [&_svg]:pointer-events-none",
        size === "sm" ? "h-3" : "h-4",
        className
      )}
      {...props}
    />
  )
}

function ButtonGroupSeparator({
  className,
  orientation = "vertical",
  ...props
}: React.ComponentProps<typeof Separator>) {
  return (
    <Separator
      data-slot="button-group-separator"
      orientation={orientation}
      className={cn(
        "relative !m-0 self-stretch bg-gray-6 data-[orientation=vertical]:h-auto",
        className
      )}
      {...props}
    />
  )
}

export {
  ButtonGroup,
  ButtonGroupSeparator,
  ButtonGroupText,
  buttonGroupVariants,
}
export type { ButtonGroupSize }

Usage

size is "default" or "sm". Default passes md (52px) and icon-md to direct child buttons that omit size. Small passes sm (39px) and icon-sm. An explicit size on a button is kept. ButtonGroupText is h-4 or h-3 to match.

size="icon" and size="icon-sm" are the 39px icon button. size="icon-md" is 52px. Icon-only buttons need an aria-label.

On a narrow screen, put groups in flex flex-wrap gap-2 so they wrap.

Joined fill buttons keep an accent-10 edge between them. Inner corners are square; the outer corners keep --aura-button-radius.

Small

export const Small = () => (
  <div className="flex flex-wrap gap-2">
    <ButtonGroup size="sm" aria-label="Archive and report">
      <Button variant="pill">Archive</Button>
      <Button variant="pill">Report</Button>
    </ButtonGroup>
    <ButtonGroup size="sm" aria-label="Snooze">
      <Button variant="pill">Snooze</Button>
      <Button variant="pill" aria-label="More options">
        <DotsHorizontalIcon className="icon" />
      </Button>
    </ButtonGroup>
  </div>
)

Button icon

A label button paired with a three-dots icon button, at the default size and at small.

export const ButtonIcon = () => (
  <div className="flex flex-wrap gap-2">
    <ButtonGroup aria-label="Snooze">
      <Button variant="pill">Snooze</Button>
      <Button variant="pill" aria-label="More options">
        <DotsHorizontalIcon className="icon" />
      </Button>
    </ButtonGroup>
    <ButtonGroup size="sm" aria-label="Snooze small">
      <Button variant="pill">Snooze</Button>
      <Button variant="pill" aria-label="More options">
        <DotsHorizontalIcon className="icon" />
      </Button>
    </ButtonGroup>
  </div>
)

Vertical

export const Vertical = () => (
  <div className="flex flex-wrap gap-2">
    <ButtonGroup orientation="vertical" aria-label="Vertical button group">
      <Button variant="fill">One</Button>
      <Button variant="fill">Two</Button>
      <Button variant="fill">Three</Button>
    </ButtonGroup>
    <ButtonGroup
      orientation="vertical"
      size="sm"
      aria-label="Small vertical button group"
    >
      <Button variant="fill">One</Button>
      <Button variant="fill">Two</Button>
    </ButtonGroup>
  </div>
)

Text

export const Text = () => (
  <div className="flex flex-wrap gap-2">
    <ButtonGroup aria-label="Amount">
      <ButtonGroupText>Amount</ButtonGroupText>
      <ButtonGroupSeparator />
      <Button variant="pill">$100</Button>
    </ButtonGroup>
    <ButtonGroup size="sm" aria-label="Small amount">
      <ButtonGroupText>Amount</ButtonGroupText>
      <ButtonGroupSeparator />
      <Button variant="pill">$100</Button>
    </ButtonGroup>
  </div>
)