Button Group
macOS 26 grouped control — attached buttons in a gray well, a backgroundless ghost group, a selectable segmented control with a raised white pill, or a split group.
The macOS 26 grouped control. A set of buttons sits inside a rounded gray "well"; adjacent buttons are flush and separated by thin hairline dividers. For a selectable group, the active item floats as a raised white pill over the well. Anatomy (well fill, hairline color, segment radius, pill shadow) is lifted from the Apple macOS 26 UI Kit "Segmented Controls" page; every color resolves through the Acrylic theme tokens so the group flips light/dark. Reuses the Acrylic Button for its items.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/button-group.jsonUsage
"use client"import * as React from "react"import { Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight } from "lucide-react"import { Button } from "@/registry/acrylic/button"import { ButtonGroup, ButtonGroupItem, ButtonGroupSeparator,} from "@/registry/acrylic/button-group"// A small macOS-style toolbar: an attached formatting cluster (one gray well,// flush neutral buttons with hairline dividers) next to a segmented alignment// control whose active item is a raised white pill that slides on selection.// (Numeric +/- increment is a Stepper, not a Button Group — see the Stepper page.)export default function ButtonGroupDemo() { const [align, setAlign] = React.useState<"left" | "center" | "right">("center") return ( <div className="flex flex-wrap items-center gap-4 text-foreground"> {/* attached: a cluster of momentary formatting actions */} <ButtonGroup> <Button variant="neutral" size="medium" aria-label="Bold"> <Bold /> </Button> <ButtonGroupSeparator /> <Button variant="neutral" size="medium" aria-label="Italic"> <Italic /> </Button> <ButtonGroupSeparator /> <Button variant="neutral" size="medium" aria-label="Underline"> <Underline /> </Button> </ButtonGroup> {/* segmented: controlled value API — the white pill SLIDES to the selection */} <ButtonGroup variant="segmented" value={align} onValueChange={(v) => setAlign(v as "left" | "center" | "right")} > <ButtonGroupItem value="left" aria-label="Align left"> <AlignLeft /> </ButtonGroupItem> <ButtonGroupItem value="center" aria-label="Align center"> <AlignCenter /> </ButtonGroupItem> <ButtonGroupItem value="right" aria-label="Align right"> <AlignRight /> </ButtonGroupItem> </ButtonGroup> </div> )}import { Button } from "@/components/acrylic/button"
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from "@/components/acrylic/button-group"
<ButtonGroup>
<Button variant="neutral">Cut</Button>
<ButtonGroupSeparator />
<Button variant="neutral">Copy</Button>
</ButtonGroup>Examples
"use client"import * as React from "react"import { Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight, Copy, Scissors, Clipboard, Bell, Star, MoreHorizontal,} from "lucide-react"import { Button } from "@/registry/acrylic/button"import { ButtonGroup, ButtonGroupItem, ButtonGroupToggle, ButtonGroupSeparator, ButtonGroupText,} from "@/registry/acrylic/button-group"// Demonstrates every Button Group variant against the acrylic theme.export default function ButtonGroupShowcase() { const [format, setFormat] = React.useState("center") const [marks, setMarks] = React.useState<string[]>(["bold"]) return ( <div className="flex flex-col gap-6 text-foreground"> {/* attached (default): shared gray well, flush buttons, hairline dividers */} <Row label="attached"> <ButtonGroup> <Button variant="neutral"> <Scissors /> Cut </Button> <ButtonGroupSeparator /> <Button variant="neutral"> <Copy /> Copy </Button> <ButtonGroupSeparator /> <Button variant="neutral"> <Clipboard /> Paste </Button> </ButtonGroup> </Row> {/* attached with a non-interactive ButtonGroupText slot */} <Row label="with text"> <ButtonGroup> <ButtonGroupText>Zoom</ButtonGroupText> <ButtonGroupSeparator /> <Button variant="neutral">100%</Button> <ButtonGroupSeparator /> <Button variant="neutral">Fit</Button> </ButtonGroup> </Row> {/* ghost: flush grouping and dividers, but no shared background well */} <Row label="ghost"> <ButtonGroup variant="ghost"> <Button variant="neutral"> <Scissors /> Cut </Button> <ButtonGroupSeparator /> <Button variant="neutral"> <Copy /> Copy </Button> <ButtonGroupSeparator /> <Button variant="neutral"> <Clipboard /> Paste </Button> </ButtonGroup> </Row> {/* toolbar: large hit area, but rect corners instead of capsule ends */} <Row label="toolbar"> <ButtonGroup variant="ghost" size="large" shape="rect"> <Button icon variant="ghost" size="large" aria-label="Notify"> <Bell /> </Button> <ButtonGroupSeparator /> <Button icon variant="ghost" size="large" aria-label="Favorite"> <Star /> </Button> <ButtonGroupSeparator /> <Button icon variant="ghost" size="large" aria-label="More"> <MoreHorizontal /> </Button> </ButtonGroup> </Row> {/* segmented: controlled value API — the raised white pill SLIDES between segments on selection (macOS easing, respects prefers-reduced-motion) */} <Row label="segmented"> <ButtonGroup variant="segmented" value={format} onValueChange={setFormat} > {( [ ["left", AlignLeft], ["center", AlignCenter], ["right", AlignRight], ] as const ).map(([value, Icon]) => ( <ButtonGroupItem key={value} value={value} aria-label={value}> <Icon /> </ButtonGroupItem> ))} </ButtonGroup> </Row> {/* segmented with labels: a wider toolbar so the slide is clearly visible */} <Row label="labeled"> <ButtonGroup variant="segmented" defaultValue="day" onValueChange={() => {}} > <ButtonGroupItem value="day">Day</ButtonGroupItem> <ButtonGroupItem value="week">Week</ButtonGroupItem> <ButtonGroupItem value="month">Month</ButtonGroupItem> <ButtonGroupItem value="year">Year</ButtonGroupItem> </ButtonGroup> </Row> {/* multiple: a toggle group (select-any) — selected marks tint to the accent; several can be on at once. The macOS B/I/U/S formatting toolbar. */} <Row label="multiple"> <ButtonGroup type="multiple" value={marks} onValueChange={setMarks}> <ButtonGroupToggle value="bold" aria-label="Bold"> <Bold /> </ButtonGroupToggle> <ButtonGroupSeparator /> <ButtonGroupToggle value="italic" aria-label="Italic"> <Italic /> </ButtonGroupToggle> <ButtonGroupSeparator /> <ButtonGroupToggle value="underline" aria-label="Underline"> <Underline /> </ButtonGroupToggle> <ButtonGroupSeparator /> <ButtonGroupToggle value="strike" aria-label="Strikethrough"> <Strikethrough /> </ButtonGroupToggle> </ButtonGroup> </Row> {/* disabled — A and B enable independently (both / one / neither). Unavailable buttons dim to 40% and drop their hover, so it's obvious at a glance which are usable. */} <Row label="disabled"> <div className="flex flex-col gap-2"> {( [ ["both", false, false], ["A only", false, true], ["B only", true, false], ["neither", true, true], ] as const ).map(([state, aOff, bOff]) => ( <div key={state} className="flex items-center gap-3"> <span className="w-14 shrink-0 text-[11px] text-muted-foreground">{state}</span> <ButtonGroup> <Button variant="neutral" disabled={aOff}>Approve</Button> <ButtonGroupSeparator /> <Button variant="neutral" disabled={bOff}>Reject</Button> </ButtonGroup> </div> ))} </div> </Row> {/* disabled in the selectable variants too: a dimmed segment / toggle */} <Row label="disabled (selectable)"> <div className="flex flex-wrap items-center gap-4"> <ButtonGroup variant="segmented" defaultValue="all"> <ButtonGroupItem value="all">All</ButtonGroupItem> <ButtonGroupItem value="active">Active</ButtonGroupItem> <ButtonGroupItem value="archived" disabled> Archived </ButtonGroupItem> </ButtonGroup> <ButtonGroup type="multiple" defaultValue={["bold"]}> <ButtonGroupToggle value="bold" aria-label="Bold"> <Bold /> </ButtonGroupToggle> <ButtonGroupSeparator /> <ButtonGroupToggle value="italic" aria-label="Italic" disabled> <Italic /> </ButtonGroupToggle> </ButtonGroup> </div> </Row> {/* split: separate buttons, small gap, no shared well */} <Row label="split"> <ButtonGroup variant="split"> <Button variant="neutral"> <Bold /> </Button> <Button variant="neutral"> <Italic /> </Button> <Button variant="neutral"> <Underline /> </Button> </ButtonGroup> </Row> </div> )}function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( <div className="flex items-start gap-3"> <span className="w-20 shrink-0 pt-1 text-xs text-muted-foreground">{label}</span> {children} </div> )}Parts
ButtonGroup— the wrapper. Renders arole="group"container and connects its children into the macOS well.ButtonGroupItem— a segment in the single-select segmented control (keyed byvalue). The sliding white pill marks the selected one.ButtonGroupToggle— a toggle in the multi-select group (type="multiple", keyed byvalue). When on it tints to the accent; several can be on at once.ButtonGroupSeparator— the hairline divider between flush items (--acr-border-soft).ButtonGroupText— a non-interactive text / label slot inside the group (e.g. a leading caption like the "Zoom" label, or a static read-only value). For a numeric increment/decrement control, use the dedicated Stepper rather than a−/value/+button group.
Variants
ButtonGroup has a variant prop:
attached(default) — action buttons sharing one rounded gray well (--acr-field), flush, with collapsed inner radii so they read as one capsule. InsertButtonGroupSeparatorbetween items for the macOS hairlines.ghost— the same flush grouping and dividers asattached, but without the shared background well. Use it when the surrounding surface already provides enough chrome.segmented— a selectable control: the same gray well, but the active item becomes a raised white pill (--acr-control) with a soft shadow. Mark the active item witharia-pressed(ordata-active); the others stay plain.split— separate buttons with a small gap and no shared well, still grouped semantically. Use for sub-groups sitting side by side.
Sizes
The kit's Segmented Control ships five sizes — mini / small / medium (default)
/ large / xl — the same scale as Button and Select: height 16 / 20 / 24 / 28 /
36px, well radius 4 / 5 / 6 / 14 / 18px, font 10 / 11 / 13 / 13 / 13px. Set size
once on ButtonGroup; the well, segments, sliding pill and dividers all scale
together.
"use client"import * as React from "react"import { Badge } from "@/registry/acrylic/badge"import { ButtonGroup, ButtonGroupItem } from "@/registry/acrylic/button-group"// The five kit sizes (1 Mn … 5 XL) of the macOS Segmented Control — height// 16/20/24/28/36, well radius 4/5/6/14/18, font 10/11/13/13/13. Set once via the// `size` prop; the well, segments, sliding pill and dividers all scale together.const SIZES = [ { size: "mini" as const, label: "mini · 16px" }, { size: "small" as const, label: "small · 20px" }, { size: "medium" as const, label: "medium · 24px" }, { size: "large" as const, label: "large · 28px" }, { size: "xl" as const, label: "xl · 36px" },]const TABS = ["Day", "Week", "Month"]export default function ButtonGroupSizes() { const [value, setValue] = React.useState<Record<string, string>>({}) return ( <div className="flex flex-col items-start gap-5 text-foreground"> {SIZES.map(({ size, label }) => ( <div key={size} className="flex items-center gap-4"> <Badge variant="secondary" className="w-[104px] justify-start"> {label} </Badge> <ButtonGroup variant="segmented" size={size} value={value[size] ?? "Week"} onValueChange={(v) => setValue((prev) => ({ ...prev, [size]: v }))} > {TABS.map((t) => ( <ButtonGroupItem key={t} value={t}> {t} </ButtonGroupItem> ))} </ButtonGroup> </div> ))} </div> )}<ButtonGroup variant="segmented" size="small" value={v} onValueChange={setV}>
<ButtonGroupItem value="day">Day</ButtonGroupItem>
<ButtonGroupItem value="week">Week</ButtonGroupItem>
</ButtonGroup>Shape
size controls height, icon scale, text scale, padding and divider inset. shape
controls only the corner geometry:
auto(default) — follows the macOS kit table.largeandxlbecome capsules.rect— keeps the same size metrics, but uses square-ish corners. This is useful for toolbar groups that need thelargehit area without pill-shaped end caps.capsule— forces fully rounded geometry at any size.
<ButtonGroup variant="ghost" size="large" shape="rect">
<Button icon variant="ghost" size="large" aria-label="Archive">
<Archive />
</Button>
<ButtonGroupSeparator />
<Button icon variant="ghost" size="large" aria-label="Delete">
<Trash2 />
</Button>
</ButtonGroup>Selection
For value-driven groups, set type (shadcn-style):
type="single"(default) — a radio. Usevariant="segmented"withButtonGroupItemchildren keyed byvalue, andvalue/defaultValue/onValueChange(a string). The raised white pill slides to the selection.type="multiple"— a toggle group ("select any"). UseButtonGroupTogglechildren keyed byvalue, andvalue/defaultValue/onValueChange(a string array). Selected toggles tint to the accent and several can be on at once — the macOS B/I/U/S formatting toolbar.
// multi-select toggle group
const [marks, setMarks] = React.useState<string[]>(["bold"])
<ButtonGroup type="multiple" value={marks} onValueChange={setMarks}>
<ButtonGroupToggle value="bold"><Bold /></ButtonGroupToggle>
<ButtonGroupSeparator />
<ButtonGroupToggle value="italic"><Italic /></ButtonGroupToggle>
</ButtonGroup>Notes
- The well uses
--acr-field(the kit's 5% black track, 5% white in dark); the selected pill uses--acr-button-group-control; the hairline uses--acr-button-group-divider. All flip with the theme. - Items reuse the Acrylic
Button. In the attached well the group renders items flat (it strips each button's own fill/shadow so the single gray surface and the hairline separators show through), so theButtonvariant you pass doesn't change their look — the well supplies the surface and a faint hover. - Disabled works per item across every variant: pass
disabledto aButton(attached / split), aButtonGroupItem(segmented), or aButtonGroupToggle(multiple). Unavailable items dim to 40% and drop their hover, so a half-enabled group clearly reads which buttons are usable. - The kit also ships an accent-blue selected segment (window-focused). The default styling targets the modern macOS 26 white-pill look; switch to an accent fill on the active item if you prefer the focused appearance.
// segmented: active item = raised white pill
<ButtonGroup variant="segmented">
<Button variant="ghost" aria-pressed={align === "left"}>
<AlignLeft />
</Button>
<Button variant="ghost" aria-pressed={align === "center"}>
<AlignCenter />
</Button>
</ButtonGroup>