Button group
Buttons joined into one control, with concentric inner radii.
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
Concentric radii
Anatomy#
- Group. A
role="group"flex row (or column) that fits its content. Name it witharia-label. - Leading button. Keeps its outer corners; its trailing corners go square.
- Seam. The next child drops its left border (top border when vertical), so neighbors share one hairline.
- Trailing button. Keeps 10px trailing corners, here a square icon button with the chevron.
Examples#
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.
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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Each child draws its own variant; the group adds nothing. |
| Hover and pressed | From Button: Well Gray on outline and ghost, a 0.96 press scale. |
| Focus visible | The focused child moves above its neighbors (z-10) so its indigo border and 3px ring aren't clipped by the next button. |
| Expanded | A chevron that opens a menu keeps its hover fill while the menu is open. |
| Disabled | Per child. A disabled button dims to 50% and the seam stays in place. |
Behavior#
- Only direct children with a
data-slottake part in the joining: Button, Input, Select trigger,ButtonGroupTextandButtonGroupSeparatorall 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
Inputgrows 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.
ButtonGroupSeparatoris vertical by default and draws a 1px rule in the input color between borderless buttons.
Do and don't#
Content#
- The main button names the default variant: Download PDF, Export CSV.
- The chevron's
aria-labelsays what the menu holds: More download formats, not More or Options. - Prefix text in
ButtonGroupTextis the literal string the value needs, such as INV- or https://.
Accessibility#
- The group is
role="group". Give it anaria-labelthat names the set, such as Download or Move between invoices. - Icon-only buttons in a group still need an
aria-labeland a tooltip. - A chevron that opens a menu is a menu trigger: it exposes
aria-haspopupandaria-expandedthrough Dropdown menu. - An input in a group needs its own label, visible or
sr-only; the prefix text isn't a label.
| Keys | Action |
|---|---|
| Tab | Moves to the next button in the group, then out. |
| Enter | Activates the focused button. |
| Space | Activates the focused button. |
Design tokens#
| Token | Used for |
|---|---|
--radius-lg | 10px outer corners |
--input | Separator rule and the outline seam |
--muted | ButtonGroupText fill |
--border | ButtonGroupText border |
API reference#
ButtonGroup
The joining row. Also exported: buttonGroupVariants, to style another element as a group.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Join left to right, or stack top to bottom. |
aria-label | string | No default | Names the group for screen readers. |
className | string | No default | Merged 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.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, such as a <label>. |
className | string | No default | Merged onto the addon. |
ButtonGroupSeparator
A 1px rule between borderless buttons.
Other props spread onto Separator.
| Prop | Type | Default | Description |
|---|---|---|---|
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.