Button Group

A container that groups related buttons with shared borders and outer corners only.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Archive, Clock, MailOpen, Trash2 } from "lucide-react"

export default function ButtonGroupDemo() {
  return (
    <ButtonGroup aria-label="Message actions">
      <Button variant="outline" size="icon" aria-label="Archive">
        <Archive />
      </Button>
      <Button variant="outline" size="icon" aria-label="Mark as read">
        <MailOpen />
      </Button>
      <Button variant="outline" size="icon" aria-label="Snooze">
        <Clock />
      </Button>
      <Button variant="outline" size="icon" aria-label="Delete">
        <Trash2 />
      </Button>
    </ButtonGroup>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation. ButtonGroup also needs the Button, which it imports ButtonGroupContext and buttonStyles from.

Copy and paste the following code into your project.

components/ui/button-group.tsx
"use client"

import { use } from "react"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { ButtonGroupContext, buttonStyles, type ButtonGroupOrientation } from "@/components/ui/button"
import { color, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: { display: "flex", width: "fit-content", alignItems: "stretch" },
  vertical: { flexDirection: "column" },
  text: {
    display: "flex",
    alignItems: "center",
    gap: space.s2,
    borderRadius: "var(--carbon-button-radius)",
    borderWidth: 1,
    borderStyle: "solid",
    backgroundColor: color.muted,
    paddingInline: space.s2_5,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    "--carbon-icon-size": space.s4,
    "--carbon-button-radius": radius.lg,
  },
  separator: {
    position: "relative",
    alignSelf: "stretch",
    flexShrink: 0,
    backgroundColor: color.buttonSecondaryEdge,
  },
  separatorVertical: { width: 1, marginInlineStart: -1 },
  separatorHorizontal: { height: 1, marginBlockStart: -1 },
})

type ButtonGroupProps = useRender.ComponentProps<"div"> & {
  orientation?: ButtonGroupOrientation
  xstyle?: StyleXStyles
}

function ButtonGroup({
  render,
  ref,
  style,
  orientation = "horizontal",
  xstyle,
  className,
  children,
  ...props
}: ButtonGroupProps) {
  const sx = stylex.props(styles.group, orientation === "vertical" && styles.vertical, xstyle)

  return (
    <ButtonGroupContext value={{ orientation }}>
      {useRender({
        defaultTagName: "div",
        render,
        ref,
        props: {
          role: "group",
          "data-slot": "button-group",
          "data-orientation": orientation,
          ...sx,
          ...props,
          className: clsx(sx.className, className),
          style: { ...sx.style, ...style },
          children,
        },
      })}
    </ButtonGroupContext>
  )
}

type ButtonGroupTextProps = useRender.ComponentProps<"div"> & {
  xstyle?: StyleXStyles
}

function ButtonGroupText({ xstyle, className, render, ...props }: ButtonGroupTextProps) {
  const grouped = use(ButtonGroupContext)
  const sx = stylex.props(styles.text, grouped && buttonStyles.group[grouped.orientation], xstyle)

  return useRender({
    defaultTagName: "div",
    render,
    props: {
      "data-slot": "button-group-text",
      ...sx,
      ...props,
      className: clsx("carbon-icons", sx.className, className),
    },
  })
}

type ButtonGroupSeparatorProps = Omit<SeparatorPrimitive.Props, "className"> & {
  xstyle?: StyleXStyles
  className?: string
}

function ButtonGroupSeparator({
  orientation,
  xstyle,
  className,
  ...props
}: ButtonGroupSeparatorProps) {
  const grouped = use(ButtonGroupContext)
  const resolved = orientation ?? (grouped?.orientation === "vertical" ? "horizontal" : "vertical")
  const sx = stylex.props(
    styles.separator,
    resolved === "vertical" ? styles.separatorVertical : styles.separatorHorizontal,
    xstyle,
  )

  return (
    <SeparatorPrimitive
      data-slot="button-group-separator"
      orientation={resolved}
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText }

Update the import paths to match your project setup.

Usage

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group"
<ButtonGroup aria-label="Message actions">
  <Button variant="outline">Archive</Button>
  <Button variant="outline">Report</Button>
  <Button variant="outline">Snooze</Button>
</ButtonGroup>

Give every group an aria-label or aria-labelledby. Without one, a screen reader announces the group with no name.

Use ButtonGroup for buttons that each perform an action. Buttons that toggle a shared state are a toggle group, which carbon does not ship yet.

Orientation

Set orientation="vertical" to stack the members.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { Minus, Plus } from "lucide-react"

export default function ButtonGroupOrientation() {
  return (
    <ButtonGroup orientation="vertical" aria-label="Zoom">
      <Button variant="outline" size="icon" aria-label="Zoom in">
        <Plus />
      </Button>
      <Button variant="outline" size="icon" aria-label="Zoom out">
        <Minus />
      </Button>
    </ButtonGroup>
  )
}

Size

The group takes no size. Set it on each button, and give every member the same one.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { VStack } from "@/components/ui/stack"

export default function ButtonGroupSize() {
  return (
    <VStack gap={4} align="start">
      <ButtonGroup aria-label="Small">
        <Button variant="outline" size="sm">
          Day
        </Button>
        <Button variant="outline" size="sm">
          Week
        </Button>
        <Button variant="outline" size="sm">
          Month
        </Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Default">
        <Button variant="outline">Day</Button>
        <Button variant="outline">Week</Button>
        <Button variant="outline">Month</Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Large">
        <Button variant="outline" size="lg">
          Day
        </Button>
        <Button variant="outline" size="lg">
          Week
        </Button>
        <Button variant="outline" size="lg">
          Month
        </Button>
      </ButtonGroup>
    </VStack>
  )
}

Side by side

Nesting a group inside another group does nothing. To place groups side by side, put them in an HStack and set the space between them with its gap.

import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
import { HStack } from "@/components/ui/stack"
import { ChevronLeft, ChevronRight } from "lucide-react"

export default function ButtonGroupNested() {
  return (
    <HStack gap={2}>
      <ButtonGroup aria-label="Pages">
        <Button variant="outline">1</Button>
        <Button variant="outline">2</Button>
        <Button variant="outline">3</Button>
      </ButtonGroup>
      <ButtonGroup aria-label="Pagination">
        <Button variant="outline" size="icon" aria-label="Previous">
          <ChevronLeft />
        </Button>
        <Button variant="outline" size="icon" aria-label="Next">
          <ChevronRight />
        </Button>
      </ButtonGroup>
    </HStack>
  )
}

Separator

Use ButtonGroupSeparator between solid variants, which have no visible border of their own. It runs across the group's axis.

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"

export default function ButtonGroupSeparatorDemo() {
  return (
    <ButtonGroup aria-label="Text alignment">
      <Button variant="secondary">Left</Button>
      <ButtonGroupSeparator />
      <Button variant="secondary">Center</Button>
      <ButtonGroupSeparator />
      <Button variant="secondary">Right</Button>
    </ButtonGroup>
  )
}

Outline buttons already share their border, so a separator between them draws a second line. Use it with default and secondary.

Split button

Pair an action with a menu that offers the alternatives. The menu trigger renders a Button, so it joins the group like any other member.

import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ChevronDown } from "lucide-react"

export default function ButtonGroupSplit() {
  return (
    <ButtonGroup aria-label="Merge">
      <Button variant="secondary">Merge pull request</Button>
      <ButtonGroupSeparator />
      <DropdownMenu>
        <DropdownMenuTrigger
          render={<Button variant="secondary" size="icon" aria-label="More merge options" />}
        >
          <ChevronDown />
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem>Squash and merge</DropdownMenuItem>
          <DropdownMenuItem>Rebase and merge</DropdownMenuItem>
          <DropdownMenuItem>Create a merge commit</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </ButtonGroup>
  )
}

With text

Use ButtonGroupText for an inert member, such as a label or a unit. Pass render to change the element.

npm install @base-ui/react
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Copy } from "lucide-react"

export default function ButtonGroupTextDemo() {
  return (
    <ButtonGroup aria-label="Install command">
      <ButtonGroupText render={<code />}>npm install @base-ui/react</ButtonGroupText>
      <Button variant="outline" size="icon" aria-label="Copy command">
        <Copy />
      </Button>
    </ButtonGroup>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

ButtonGroup

Renders a div with role="group". A member is any child carrying a data-slot.

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
xstyleStyleXStyles—
classNamestring—

ButtonGroupSeparator

Wraps Base UI's Separator.

PropTypeDefault
orientation"horizontal" | "vertical"across the group's axis
xstyleStyleXStyles—
classNamestring—

ButtonGroupText

Renders a div through Base UI's useRender, so render swaps the element.

PropTypeDefault
renderReactElement—
xstyleStyleXStyles—
classNamestring—

Data attributes

AttributeOnEffect
data-slot="button-group"the groupTargets the group from a parent
data-orientationthe groupReflects the orientation prop
data-slot="button-group-separator"the separatorTargets the separator
data-slot="button-group-text"the textTargets the text member