Button Group
Joins related buttons, inputs and text into a single control so they read as one set of actions.
import { ArchiveIcon, ClockIcon, FlagIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export default function ButtonGroupDemo() {
return (
<ButtonGroup aria-label="Message actions">
<Button variant="outline">
<ArchiveIcon data-icon="inline-start" />
Archive
</Button>
<Button variant="outline">
<ClockIcon data-icon="inline-start" />
Snooze
</Button>
<Button variant="outline">
<FlagIcon data-icon="inline-start" />
Flag
</Button>
</ButtonGroup>
)
}Anatomy
- 1GroupJoins its children into one control: borders merge and inner corners square off. It carries role="group" and should have an aria-label.
- 2ActionAny prfct button. Keep one variant across the group so it reads as a unit.
- 3SeparatorDivides filled buttons, which have no borders of their own to meet.
- 4Menu buttonOpens related options — the split-button pattern. An icon-only button, so it needs an aria-label.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/button-group.jsonUsage
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"<ButtonGroup aria-label="Message actions">
<Button variant="outline">Archive</Button>
<Button variant="outline">Snooze</Button>
<Button variant="outline">Flag</Button>
</ButtonGroup>The group squares off the inner corners of its children and collapses their shared borders. It works with anything that carries a data-slot — buttons, inputs, select triggers, dropdown triggers and ButtonGroupText.
Examples
Orientation
orientation="vertical" stacks the items and joins them top to bottom — the classic zoom control.
import { MinusIcon, PlusIcon, ScanIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export default function ButtonGroupOrientation() {
return (
<ButtonGroup orientation="vertical" aria-label="Map zoom">
<Button variant="outline" size="icon" aria-label="Zoom in">
<PlusIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Zoom out">
<MinusIcon />
</Button>
<Button variant="outline" size="icon" aria-label="Fit to screen">
<ScanIcon />
</Button>
</ButtonGroup>
)
}With separators
Filled variants have no borders to share, so add ButtonGroupSeparator between items to keep them distinct.
import { ClipboardPasteIcon, CopyIcon, ScissorsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
export default function ButtonGroupWithSeparator() {
return (
<ButtonGroup aria-label="Clipboard">
<Button variant="secondary" size="sm">
<ScissorsIcon data-icon="inline-start" />
Cut
</Button>
<ButtonGroupSeparator />
<Button variant="secondary" size="sm">
<CopyIcon data-icon="inline-start" />
Copy
</Button>
<ButtonGroupSeparator />
<Button variant="secondary" size="sm">
<ClipboardPasteIcon data-icon="inline-start" />
Paste
</Button>
</ButtonGroup>
)
}With inputs
Attach a button to an input for search and copy fields, and use ButtonGroupText for fixed prefixes and suffixes like protocols and domains.
import { CopyIcon, SearchIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function ButtonGroupWithInput() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<ButtonGroup className="w-full">
<Input placeholder="Search issues" aria-label="Search issues" />
<Button variant="outline" size="icon" aria-label="Search">
<SearchIcon />
</Button>
</ButtonGroup>
<div className="flex flex-col gap-2">
<Label htmlFor="subdomain">Workspace URL</Label>
<ButtonGroup className="w-full">
<ButtonGroupText>https://</ButtonGroupText>
<Input id="subdomain" defaultValue="acme" />
<ButtonGroupText>.prfct.dev</ButtonGroupText>
<Button variant="outline" size="icon" aria-label="Copy URL">
<CopyIcon />
</Button>
</ButtonGroup>
</div>
</div>
)
}Split button
Pair a primary action with a menu of alternatives. The main button runs the most common option; the chevron opens the rest.
import {
ChevronDownIcon,
GitMergeIcon,
GitPullRequestIcon,
LayersIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupSeparator } from "@/components/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function ButtonGroupSplitButton() {
return (
<ButtonGroup>
<Button>
<GitMergeIcon data-icon="inline-start" />
Merge pull request
</Button>
<ButtonGroupSeparator className="bg-primary-foreground/30" />
<DropdownMenu>
<DropdownMenuTrigger
render={<Button size="icon" aria-label="More merge options" />}
>
<ChevronDownIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuItem>
<GitMergeIcon />
Create a merge commit
</DropdownMenuItem>
<DropdownMenuItem>
<LayersIcon />
Squash and merge
</DropdownMenuItem>
<DropdownMenuItem>
<GitPullRequestIcon />
Rebase and merge
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
)
}<ButtonGroup>
<Button>Merge pull request</Button>
<ButtonGroupSeparator className="bg-primary-foreground/30" />
<DropdownMenu>
<DropdownMenuTrigger render={<Button size="icon" aria-label="More merge options" />}>
<ChevronDownIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">…</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>Nested groups
Groups nested inside a group keep their own joined edges and are spaced apart, which is how toolbars cluster related tools.
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
UnderlineIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { ButtonGroup } from "@/components/ui/button-group"
export default function ButtonGroupNested() {
return (
<ButtonGroup aria-label="Text formatting">
<ButtonGroup aria-label="Style">
<Button variant="outline" size="icon-sm" aria-label="Bold">
<BoldIcon />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Italic">
<ItalicIcon />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Underline">
<UnderlineIcon />
</Button>
</ButtonGroup>
<ButtonGroup aria-label="Alignment">
<Button variant="outline" size="icon-sm" aria-label="Align left">
<AlignLeftIcon />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Align center">
<AlignCenterIcon />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Align right">
<AlignRightIcon />
</Button>
</ButtonGroup>
</ButtonGroup>
)
}Guidelines
When to use
- For a small set of related, equally weighted actions on the same object: Archive, Snooze, Flag.
- To attach an action to an input, like search or copy.
- For split buttons, where one action is the default and the others are variations of it.
When not to use
- To let people choose between options — use a Toggle Group, which tracks which item is selected.
- For actions of different importance. A group implies equal weight; a primary action and a cancel button belong in separate buttons with space between them.
- For long lists of actions. Past four or five items, move the rest into a Dropdown Menu.
Keep variants consistent
Every item in a group should share one variant and one size. Mixed variants break the joined silhouette that makes a group read as one control.
Accessibility
- The group renders
role="group". Give it anaria-label(oraria-labelledby) that names the set — Message actions, Map zoom — so screen readers announce the context when focus enters it. - Each child keeps its own semantics and its own tab stop. Unlike a Toggle Group, there is no roving focus: buttons in a group are independent actions.
- Icon-only buttons inside a group still need an
aria-label. - Focused items are raised above their neighbors, so the focus ring is never clipped by an adjacent border.
| Key | Behavior |
|---|---|
TabShiftTab | Moves focus between the buttons, inputs and triggers in the group. |
EnterSpace | Activates the focused button. |
API reference
ButtonGroup
Renders a <div> with role="group" and data-slot="button-group".
ButtonGroupText
A non-interactive segment for prefixes, suffixes and labels. Renders a <div>; use render to swap the element.
ButtonGroupSeparator
A hairline between items, for variants without borders. Built on Separator.
buttonGroupVariants
The class generator, exported for custom elements that should join like a group.