Skip to content

Button group

Buttons joined into one control, with concentric inner radii.

Status
Beta
Category
Actions
Adoption
Not used yet
import { ButtonGroup } from "@oration/canon/components/button-group";
packages/canon/src/components/button-group.tsx

INV-20418

1 of 4 awaiting approval

import { Button } from "@oration/canon/components/button";import { ButtonGroup } from "@oration/canon/components/button-group";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuGroup,  DropdownMenuItem,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ChevronDownIcon, ChevronUpIcon, DownloadIcon } from "lucide-react";import * as React from "react";export function Hero() {    const invoices = ["INV-20418", "INV-20411", "INV-20407", "INV-20399"];    const [index, setIndex] = React.useState(0);    const current = invoices[index] ?? invoices[0];    return (        <div className="flex w-full max-w-2xl flex-wrap items-center justify-between gap-3 rounded-xl bg-card px-4 py-3 text-left shadow-border">            <div className="flex min-w-0 items-center gap-3">                <ButtonGroup                    orientation="vertical"                    aria-label="Move between invoices"                >                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="outline"                                    size="icon-xs"                                    aria-label="Previous invoice"                                    disabled={index === 0}                                    onClick={() => setIndex((i) => i - 1)}                                />                            }                        >                            <ChevronUpIcon aria-hidden="true" />                        </TooltipTrigger>                        <TooltipContent>Previous invoice</TooltipContent>                    </Tooltip>                    <Tooltip>                        <TooltipTrigger                            render={                                <Button                                    type="button"                                    variant="outline"                                    size="icon-xs"                                    aria-label="Next invoice"                                    disabled={index === invoices.length - 1}                                    onClick={() => setIndex((i) => i + 1)}                                />                            }                        >                            <ChevronDownIcon aria-hidden="true" />                        </TooltipTrigger>                        <TooltipContent>Next invoice</TooltipContent>                    </Tooltip>                </ButtonGroup>                <div className="min-w-0">                    <p className="font-mono text-sm font-medium">{current}</p>                    <p className="text-13 text-muted-foreground tabular-nums">                        {index + 1} of {invoices.length} awaiting approval                    </p>                </div>            </div>            <div className="flex items-center gap-2">                <ButtonGroup aria-label="Download">                    <Button                        type="button"                        variant="outline"                        onClick={() =>                            toast.add({ title: `${current}.pdf downloaded` })                        }                    >                        <DownloadIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Download PDF                    </Button>                    <DropdownMenu>                        <DropdownMenuTrigger                            render={                                <Button                                    type="button"                                    variant="outline"                                    size="icon"                                    aria-label="More download formats"                                />                            }                        >                            <ChevronDownIcon aria-hidden="true" />                        </DropdownMenuTrigger>                        <DropdownMenuContent align="end" className="w-52">                            <DropdownMenuGroup>                                <DropdownMenuItem                                    onClick={() =>                                        toast.add({                                            title: `${current}.csv downloaded`,                                        })                                    }                                >                                    CSV for your ERP                                </DropdownMenuItem>                                <DropdownMenuItem                                    onClick={() =>                                        toast.add({                                            title: `${current} remittance downloaded`,                                        })                                    }                                >                                    Remittance advice                                </DropdownMenuItem>                            </DropdownMenuGroup>                        </DropdownMenuContent>                    </DropdownMenu>                </ButtonGroup>                <Button                    type="button"                    onClick={() =>                        toast.add({                            type: "success",                            title: `${current} approved`,                            description: "It joins Friday's payment run.",                        })                    }                >                    Approve                </Button>            </div>        </div>    );}

Usage#

Button group joins buttons, inputs and text addons into one control: the inner corners go square, neighboring borders collapse into one hairline and the outer corners keep the button radius. It is for actions that belong together, most often a split button where a chevron opens variants of the main action. The agent header's preview button is the product's one use today. The common mistake is joining unrelated actions, or mixing variants inside one group, which turns a toolbar into a single confusing shape.

When to use

  • For a split button: a main action joined to a chevron that opens its variants, such as Download PDF with CSV and remittance formats.
  • For a pair of icon buttons that act as one control, such as previous and next record, or zoom in and out.
  • For a fixed prefix or suffix joined to an input and its action, such as INV- before an invoice number.
  • For two or three closely related actions on the same object, all in the same variant.

When not to use

  • For a set of modes where one is selected. Use Segmented control
  • For toggles that stay pressed, such as bold and italic. Use Toggle group
  • For a row of unrelated page actions. Space them with a 6px gap instead. Use Toolbar
  • For an input with an icon or a button inside its border. Use Input group
  • For a long list of actions behind one trigger. Use Dropdown menu

The One Filled Button Rule

A group counts as one control. Keep its buttons outline, secondary or ghost; if the view's primary action has variants, the filled button stands alone and the variants move to a menu.

Concentric radii

Only the outer corners of the group keep the button radius. Inner corners are square so the group reads as one shape.

Anatomy#

  1. Group. A role="group" flex row (or column) that fits its content. Name it with aria-label.
  2. Leading button. Keeps its outer corners; its trailing corners go square.
  3. Seam. The next child drops its left border (top border when vertical), so neighbors share one hairline.
  4. Trailing button. Keeps 10px trailing corners, here a square icon button with the chevron.

Examples#

Split button

A main action joined to a square chevron that opens related variants in a menu. The chevron names itself with aria-label.

import { Button } from "@oration/canon/components/button";import { ButtonGroup } from "@oration/canon/components/button-group";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { ChevronDownIcon } from "lucide-react";export function SplitButton() {    return (        <ButtonGroup aria-label="Export payment run">            <Button                type="button"                variant="outline"                onClick={() =>                    toast.add({ title: "Payment run exported as CSV" })                }            >                Export CSV            </Button>            <DropdownMenu>                <DropdownMenuTrigger                    render={                        <Button                            type="button"                            variant="outline"                            size="icon"                            aria-label="More export formats"                        />                    }                >                    <ChevronDownIcon aria-hidden="true" />                </DropdownMenuTrigger>                <DropdownMenuContent align="end" className="w-48">                    <DropdownMenuItem                        onClick={() =>                            toast.add({                                title: "Payment run exported as NACHA",                            })                        }                    >                        NACHA file                    </DropdownMenuItem>                    <DropdownMenuItem                        onClick={() =>                            toast.add({ title: "Payment run exported as PDF" })                        }                    >                        PDF summary                    </DropdownMenuItem>                </DropdownMenuContent>            </DropdownMenu>        </ButtonGroup>    );}

Orientation

Horizontal joins left to right and drops the left border of every button after the first. orientation="vertical" stacks them and drops the top border instead.

100%
import { Button } from "@oration/canon/components/button";import { ButtonGroup } from "@oration/canon/components/button-group";import { toast } from "@oration/canon/components/toast";import { ArchiveIcon, CopyIcon, MinusIcon, PencilIcon, PlusIcon } from "lucide-react";import * as React from "react";export function Orientation() {    const [zoom, setZoom] = React.useState(100);    return (        <div className="flex flex-wrap items-center gap-8">            <ButtonGroup aria-label="Supplier actions">                <Button                    type="button"                    variant="outline"                    onClick={() =>                        toast.add({ title: "Editing Northwind Freight" })                    }                >                    <PencilIcon data-icon="inline-start" aria-hidden="true" />                    Edit                </Button>                <Button                    type="button"                    variant="outline"                    onClick={() =>                        toast.add({ title: "Northwind Freight duplicated" })                    }                >                    <CopyIcon data-icon="inline-start" aria-hidden="true" />                    Duplicate                </Button>                <Button                    type="button"                    variant="outline"                    onClick={() =>                        toast.add({ title: "Northwind Freight archived" })                    }                >                    <ArchiveIcon data-icon="inline-start" aria-hidden="true" />                    Archive                </Button>            </ButtonGroup>            <div className="flex items-center gap-3">                <ButtonGroup orientation="vertical" aria-label="Zoom">                    <Button                        type="button"                        variant="outline"                        size="icon-sm"                        aria-label="Zoom in"                        disabled={zoom >= 200}                        onClick={() => setZoom((z) => z + 25)}                    >                        <PlusIcon aria-hidden="true" />                    </Button>                    <Button                        type="button"                        variant="outline"                        size="icon-sm"                        aria-label="Zoom out"                        disabled={zoom <= 50}                        onClick={() => setZoom((z) => z - 25)}                    >                        <MinusIcon aria-hidden="true" />                    </Button>                </ButtonGroup>                <span className="text-13 text-muted-foreground tabular-nums">                    {zoom}%                </span>            </div>        </div>    );}

Text and input

ButtonGroupText adds a fixed prefix on Well Gray. An Input in the group stretches to fill the space between.

INV-
import { Button } from "@oration/canon/components/button";import { ButtonGroup, ButtonGroupText } from "@oration/canon/components/button-group";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function TextAndInput() {    const id = React.useId();    const [number, setNumber] = React.useState("20418");    return (        <form            className="w-full max-w-xs"            onSubmit={(event) => {                event.preventDefault();                toast.add({                    title: number                        ? `Opening INV-${number}`                        : "Enter an invoice number",                    type: number ? "info" : "error",                });            }}        >            <label htmlFor={id} className="sr-only">                Invoice number            </label>            <ButtonGroup aria-label="Find an invoice" className="w-full">                <ButtonGroupText>INV-</ButtonGroupText>                <Input                    id={id}                    inputMode="numeric"                    value={number}                    onChange={(event) => setNumber(event.target.value)}                    className="tabular-nums"                />                <Button type="submit" variant="outline">                    Open                </Button>            </ButtonGroup>        </form>    );}

With a separator

Secondary and ghost buttons have no border to join on. ButtonGroupSeparator draws a 1px rule between them.

Page 1 of 9
import { Button } from "@oration/canon/components/button";import { ButtonGroup, ButtonGroupSeparator } from "@oration/canon/components/button-group";import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";import * as React from "react";export function WithSeparator() {    const [page, setPage] = React.useState(1);    return (        <div className="flex items-center gap-3">            <span className="text-13 text-muted-foreground tabular-nums">                Page {page} of 9            </span>            <ButtonGroup aria-label="Pages">                <Button                    type="button"                    variant="secondary"                    size="sm"                    disabled={page === 1}                    onClick={() => setPage((p) => p - 1)}                >                    <ChevronLeftIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Previous                </Button>                <ButtonGroupSeparator />                <Button                    type="button"                    variant="secondary"                    size="sm"                    disabled={page === 9}                    onClick={() => setPage((p) => p + 1)}                >                    Next                    <ChevronRightIcon                        data-icon="inline-end"                        aria-hidden="true"                    />                </Button>            </ButtonGroup>        </div>    );}

States#

Rest
Focus on the middle
import { Button } from "@oration/canon/components/button";import { ButtonGroup } from "@oration/canon/components/button-group";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        {            name: "Focus on the middle",            className: "relative z-10 border-ring ring-3 ring-ring/40",        },    ];    return (        <div className="flex w-full flex-wrap gap-8" inert>            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-start gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <ButtonGroup aria-label={state.name}>                        <Button type="button" variant="outline">                            Edit                        </Button>                        <Button                            type="button"                            variant="outline"                            className={state.className}                        >                            Duplicate                        </Button>                        <Button type="button" variant="outline">                            Archive                        </Button>                    </ButtonGroup>                </div>            ))}        </div>    );}
States
StateTreatment
RestEach child draws its own variant; the group adds nothing.
Hover and pressedFrom Button: Well Gray on outline and ghost, a 0.96 press scale.
Focus visibleThe focused child moves above its neighbors (z-10) so its indigo border and 3px ring aren't clipped by the next button.
ExpandedA chevron that opens a menu keeps its hover fill while the menu is open.
DisabledPer child. A disabled button dims to 50% and the seam stays in place.

Behavior#

  • Only direct children with a data-slot take part in the joining: Button, Input, Select trigger, ButtonGroupText and ButtonGroupSeparator all have one.
  • Each button is its own tab stop. There's no roving focus, so Tab moves through the group one button at a time.
  • An Input grows to fill the group (flex-1); a Select trigger shrinks to its content unless it has a width class.
  • Groups nested in a group sit 8px apart, for a toolbar made of several joined controls.
  • Small and extra-small buttons switch to 10px corners inside a group so the outer corners match.
  • ButtonGroupSeparator is vertical by default and draws a 1px rule in the input color between borderless buttons.

Do and don't#

Do. Join a main action to its variants, in one variant.
Don't. Join unrelated actions in mixed variants. Edit, Export, a red Delete and a filled Approve read as one broken control.

Content#

  • The main button names the default variant: Download PDF, Export CSV.
  • The chevron's aria-label says what the menu holds: More download formats, not More or Options.
  • Prefix text in ButtonGroupText is the literal string the value needs, such as INV- or https://.

Accessibility#

  • The group is role="group". Give it an aria-label that names the set, such as Download or Move between invoices.
  • Icon-only buttons in a group still need an aria-label and a tooltip.
  • A chevron that opens a menu is a menu trigger: it exposes aria-haspopup and aria-expanded through Dropdown menu.
  • An input in a group needs its own label, visible or sr-only; the prefix text isn't a label.
Keyboard interactions
KeysAction
TabMoves to the next button in the group, then out.
EnterActivates the focused button.
SpaceActivates the focused button.

Design tokens#

Design tokens
TokenUsed for
--radius-lg10px outer corners
--inputSeparator rule and the outline seam
--mutedButtonGroupText fill
--borderButtonGroupText border

API reference#

ButtonGroup

The joining row. Also exported: buttonGroupVariants, to style another element as a group.

Other props spread onto <div>.

Props of ButtonGroup
PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Join left to right, or stack top to bottom.
aria-labelstringNo defaultNames the group for screen readers.
classNamestringNo defaultMerged onto the group, for width.

ButtonGroupText

A fixed text addon on Well Gray with a border and 10px corners, 14px medium.

Other props spread onto <div> through Base UI useRender.

Props of ButtonGroupText
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender as another element, such as a <label>.
classNamestringNo defaultMerged onto the addon.

ButtonGroupSeparator

A 1px rule between borderless buttons.

Other props spread onto Separator.

Props of ButtonGroupSeparator
PropTypeDefaultDescription
orientation"horizontal" | "vertical""vertical"Vertical between side-by-side buttons; horizontal in a vertical group.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

DESIGN.md says buttons inside groups step down to 8px corners. In code, small and extra-small buttons step up to 10px (in-data-[slot=button-group]:rounded-lg), and inner corners go square instead.

ButtonGroupText draws a CSS border in --border, while the outline buttons beside it use --input in dark mode, so the seam color can differ.

Nothing enforces a name: role="group" without aria-label is announced as an unnamed group.

One product use, the agent header's preview split button.