Button Group
Cluster of buttons (or selects) joined by a shared border, with optional separators and label text.
Overview
ButtonGroup joins multiple Button siblings into a single visual unit by neutralizing inner borders and rounding only the first and last child. Use it for related actions that share a context: copy / paste / cut, alignment, view modes, pagination.
It also accepts Select triggers and ButtonGroupText so you can mix labels and controls in the same border-shared row. ButtonGroupSeparator adds a hairline between groups when you need a visual beat.
Preview
'use client';import { Button, ButtonGroup, ButtonGroupSeparator } from '@gremorie/rx-forms';export function ButtonGroupPreview() { return ( <ButtonGroup> <Button variant="outline">Copy</Button> <ButtonGroupSeparator /> <Button variant="outline">Paste</Button> <ButtonGroupSeparator /> <Button variant="outline">Cut</Button> </ButtonGroup> );}Anatomy
ButtonGroup role="group" flex wrapper that fuses its children
├─ ButtonGroupText non-interactive text/label segment (muted addon)
└─ ButtonGroupSeparator thin Radix Separator between segmentsInstallation
bash npx gremorie@latest add rx-button-group bash pnpm dlx gremorie@latest add rx-button-group
bash yarn dlx gremorie@latest add rx-button-group
bash bunx --bun gremorie@latest add rx-button-group
Usage
import {
Button,
ButtonGroup,
ButtonGroupSeparator,
} from "@gremorie/rx-forms";
export function Example() {
return (
<ButtonGroup>
<Button variant="outline">Copy</Button>
<ButtonGroupSeparator />
<Button variant="outline">Paste</Button>
</ButtonGroup>
);
}The Angular edition of this component ships from source today (see the workbench for the side-by-side); its registry entry is coming next.
API
<ButtonGroup>
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Lays children side by side or stacked. Borders adapt: horizontal strips border-l, vertical strips border-t. |
Extends all React.ComponentProps<"div">. Always rendered with role="group" and data-slot="button-group".
<ButtonGroupSeparator>
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "vertical" | Radix Separator direction. Defaults to a vertical 1px line for horizontal groups. |
Wraps @radix-ui/react-separator. Decorative (forced decorative flag), so it does not announce to screen readers.
<ButtonGroupText>
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as Slot.Root so the first child receives the muted/bordered surface. Use for non-interactive labels (e.g. "of 12", a unit). |
Extends all React.ComponentProps<"div">.
Composition
<ButtonGroup>strips inner borders and rounds only the first / last child via CSS sibling selectors.- Children can be:
<Button>,<Select>triggers (when wrapped inSelectTrigger), or<ButtonGroupText>for labels. <ButtonGroupSeparator>inserts a visible 1px divider between children that share a border.<ButtonGroupText>is the right element for static labels inside the cluster - never use a raw<span>or<div>since the group expects its children to participate in border merging.
Action rows and size pairing
An action row is any horizontal cluster of controls: a toolbar, a card header with a select and an icon button, a filter bar. Two layouts exist, and one sizing rule governs both.
Grouped vs gapped
| Layout | When | How |
|---|---|---|
<ButtonGroup> | The actions form ONE unit: segmented modes, copy/paste/cut, pagination. Borders merge, the cluster reads as a single control. | Wrap the children in ButtonGroup; see Composition above. |
| Gapped row | The actions are independent: a theme select next to a dark-mode toggle, a search input next to a submit. Each control keeps its own border. | <div className="flex items-center gap-2"> |
The sizing rule
Adjacent controls in a row must share the same height step. Pick the step, then use the matching size variant on every control:
| Height | Button | Icon Button | Select | Input |
|---|---|---|---|---|
24px (h-6) | size="xs" | size="icon-xs" | - | - |
32px (h-8) | size="sm" | size="icon-sm" | size="sm" | className="h-8" (no variant yet) |
36px (h-9) | default | size="icon" | default | default |
40px (h-10) | size="lg" | size="icon-lg" | - | - |
Never override a control's height with a className when a size variant exists. The variant owns the height (Select applies it via data-[size=...] selectors), so a manual h-8 on a default-size trigger loses to the variant's h-9 and the row misaligns by 4px.
// Wrong: manual height fights the size variant and loses
<div className="flex items-center gap-2">
<SelectTrigger className="h-8 w-36">...</SelectTrigger> {/* renders 36px */}
<Button variant="outline" size="icon-sm">...</Button> {/* renders 32px */}
</div>
// Right: same step, matching variants
<div className="flex items-center gap-2">
<SelectTrigger size="sm" className="w-36">...</SelectTrigger> {/* 32px */}
<Button variant="outline" size="icon-sm">...</Button> {/* 32px */}
</div>The same contract applies in the Angular edition: the size inputs mirror these variants one to one, so a mixed React and Angular surface stays aligned by construction.
Variations
Three actions with separators
Use for related commands. Separators communicate that each action is distinct, even though they share a visual surface.
<ButtonGroup>
<Button variant="outline">Copy</Button>
<ButtonGroupSeparator />
<Button variant="outline">Paste</Button>
<ButtonGroupSeparator />
<Button variant="outline">Cut</Button>
</ButtonGroup>Vertical orientation
Switch orientation to stack actions in a compact column - useful for image editors, video players, and any tool palette.
'use client';import { Button, ButtonGroup } from '@gremorie/rx-forms';import { Bold, Italic, Underline } from 'lucide-react';export function ButtonGroupVerticalPreview() { return ( <ButtonGroup orientation="vertical"> <Button size="icon" variant="outline" aria-label="Bold"> <Bold /> </Button> <Button size="icon" variant="outline" aria-label="Italic"> <Italic /> </Button> <Button size="icon" variant="outline" aria-label="Underline"> <Underline /> </Button> </ButtonGroup> );}Text label addon
ButtonGroupText renders a non-interactive label that shares the group's border, so prefixes and units merge seamlessly with the buttons.
'use client';import { Button, ButtonGroup, ButtonGroupText } from '@gremorie/rx-forms';export function ButtonGroupTextPreview() { return ( <ButtonGroup> <ButtonGroupText>https://</ButtonGroupText> <Button variant="outline">gremorie.com</Button> </ButtonGroup> );}Pagination with label text
Combine ButtonGroupText with arrow buttons. The text stays non-interactive but inherits the group's surface.
import { ChevronLeft, ChevronRight } from 'lucide-react';
<ButtonGroup>
<Button variant="outline" size="icon" aria-label="Previous page">
<ChevronLeft />
</Button>
<ButtonGroupText>Page 3 of 12</ButtonGroupText>
<Button variant="outline" size="icon" aria-label="Next page">
<ChevronRight />
</Button>
</ButtonGroup>;Accessibility
- Group semantics: the wrapper renders
role="group". Providearia-label(oraria-labelledby) so screen readers announce what the cluster represents. - Keyboard: each child remains an independent focusable element.
TabandShift+Tabmove between them; there is no roving tabindex (useToggleGroupif you want that). - Focus: focused child elevates above its siblings (
z-10) so the focus ring is never clipped by the adjacent button's border. - Separators: rendered as decorative so they do not appear in the accessibility tree.
Related
- Button - the building block
- Toggle Group - same visual idea but with coordinated
aria-pressedstate and roving tabindex - Input Group - the input + addon equivalent
- Select - allowed inside
ButtonGroupwhen you need a dropdown trigger in the cluster