ButtonGroup
Weld related buttons into one control. Wrap any number of Buttons — icon, label, or icon-only — and the group squares their touching corners and draws a clearly visible divider between each.
Toolbar
Group icon buttons into a compact toolbar. Each stays a real Button with its own hover, focus, and aria-label; the group attaches them, separates them with a strong full-height divider, and shares one variant so they read as a set.
"use client";
import { Button, ButtonGroup } from "@stridge/noctis";
import { Bold, Italic, Underline } from "lucide-react";
export default function ButtonGroupToolbar() {
return (
<ButtonGroup variant="secondary" aria-label="Text formatting">
<Button iconOnly aria-label="Bold" startIcon={Bold} />
<Button iconOnly aria-label="Italic" startIcon={Italic} />
<Button iconOnly aria-label="Underline" startIcon={Underline} />
</ButtonGroup>
);
}
Shared size & variant
Set variant and size on the group and every child Button inherits them — that's the point of a group, so you set the look once instead of repeating it. A child that sets its own variant/size still wins, and inheritance flows through render, so a Menu.Trigger rendered as a Button (the split button below) matches too.
"use client";
import { Button, ButtonGroup } from "@stridge/noctis";
import { Bold, Italic, Underline } from "lucide-react";
const SIZES: Button.Size[] = ["sm", "md", "lg"];
export default function ButtonGroupSizes() {
return (
<div className="flex flex-col items-center gap-4">
{SIZES.map((size) => (
// The group owns the size; the bare buttons inherit it.
<ButtonGroup key={size} variant="secondary" size={size} aria-label={`Text formatting (${size})`}>
<Button iconOnly aria-label="Bold" startIcon={Bold} />
<Button iconOnly aria-label="Italic" startIcon={Italic} />
<Button iconOnly aria-label="Underline" startIcon={Underline} />
</ButtonGroup>
))}
</div>
);
}
With labels
Buttons with labels work the same — a segmented set of related actions reads as one unit.
"use client";
import { Button, ButtonGroup } from "@stridge/noctis";
import { LayoutGrid, List, Rows3 } from "lucide-react";
export default function ButtonGroupWithLabels() {
return (
<ButtonGroup variant="secondary" aria-label="View">
<Button startIcon={LayoutGrid}>Board</Button>
<Button startIcon={List}>List</Button>
<Button startIcon={Rows3}>Timeline</Button>
</ButtonGroup>
);
}
Split button
The split-button pattern — a primary action welded to a caret that opens more options — is just a ButtonGroup with a Menu: the action is a Button, and the caret is a Menu.Trigger rendered as an icon-only Button. No bespoke component; compose the two primitives.
"use client";
import { Button, ButtonGroup, Menu } from "@stridge/noctis";
import { ChevronDown, Copy, FileText } from "lucide-react";
export default function ButtonGroupSplit() {
return (
<Menu.Root>
<ButtonGroup variant="secondary">
<Button startIcon={Copy}>Copy page</Button>
<Menu.Trigger
render={
<Button
iconOnly
aria-label="More copy options"
startIcon={ChevronDown}
className="data-[popup-open]:bg-control-hover data-[popup-open]:text-foreground"
/>
}
/>
</ButtonGroup>
<Menu.Content align="end">
<Menu.Item icon={Copy}>Copy page</Menu.Item>
<Menu.Item icon={FileText}>View as Markdown</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
Anatomy
ButtonGroup is a thin wrapper — it owns the shared look and attaches its children. Compose it from the Button primitive (and, for the split pattern, Menu):
- Set
variantandsizeon the group; each childButtoninherits them unless it sets its own (an explicit child prop wins). Inheritance reaches buttons rendered throughrendertoo. - Each child stays a regular
Button— itsiconOnly, icons,aria-label, and handlers work as usual. - The group squares the inner corners (each end keeps its own button's outer radius) and pulls segments together so adjacent borders collapse to a single seam.
- The divider at each seam is a full-height line in the high-contrast
strongtoken — drawn in CSS, not as a separate element — so it stays legible across button variants. Everything is logical, so it mirrors under RTL.
The wrapper carries data-slot="button-group"; target the child buttons by their own data-slot="button".
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every group in that region retunes — e.g. .toolbar { --noctis-button-group-seam-color: var(--noctis-color-border); } softens the divider between buttons. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source.