Joins buttons that act on the same thing into one control. Its companion SplitButton runs one main action and keeps the rarer variants in a menu beside it (also called a dropdown or menu button).
import { ButtonGroup, SplitButton } from "@umriss-ui/core";
Name every group with an aria-label; a screen reader otherwise announces only "group". Each button in it stays its own Tab stop.
A group holds no selection: it does not remember which button was pressed.
Buttons that act on the same thing, joined; aria-label names the group, which a screen reader otherwise calls just "group".
import { Button, ButtonGroup } from "@umriss-ui/core";
export default function Group() {
return (
<ButtonGroup aria-label="Browse incidents">
<Button>Previous incident</Button>
<Button>Next incident</Button>
</ButtonGroup>
);
}
Give every button in a group the same variant and size; a group that mixes them no longer reads as one control. size on the group says it once for all of them.
import { Button, ButtonGroup, Stack } from "@umriss-ui/core";
export default function VariantsAndSizes() {
return (
<Stack gap={3} align="flex-start">
<ButtonGroup aria-label="Zoom the tour plan">
<Button>Zoom out</Button>
<Button>Zoom in</Button>
<Button>Fit</Button>
</ButtonGroup>
<ButtonGroup aria-label="Tour T-03">
<Button variant="primary">Dispatch</Button>
<Button variant="primary">Print manifest</Button>
</ButtonGroup>
<ButtonGroup aria-label="Stops" size="sm">
<Button variant="ghost">Move up</Button>
<Button variant="ghost">Move down</Button>
<Button variant="ghost">Remove</Button>
</ButtonGroup>
</Stack>
);
}
A SplitButton runs its main action on a click and keeps the rarer variants in its menu, found but not hit by accident. This one stands at the start of its bar, so align="start" opens the menu along the bar rather than out over its edge.
import { useState } from "react";
import { MenuItem, MenuSeparator, SplitButton, Stack, Text } from "@umriss-ui/core";
export default function SplitButtonExample() {
const [last, setLast] = useState("nothing yet");
return (
<Stack gap={3} align="flex-start">
<SplitButton
variant="primary"
align="start"
onClick={() => setLast("labels for tour T-03")}
menu={
<>
<MenuItem onSelect={() => setLast("the loading manifest")}>Print the loading manifest</MenuItem>
<MenuItem onSelect={() => setLast("the delivery notes")}>Print delivery notes</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={() => setLast("nothing - print jobs cancelled")}>
Cancel all print jobs
</MenuItem>
</>
}
>
Print labels
</SplitButton>
<Text size="xs" tone="muted">
Printed: {last}
</Text>
</Stack>
);
}
Set disabled on the split button and both halves lock: the main action and its menu wait for a selection.
import { useState } from "react";
import { Checkbox, MenuItem, SplitButton, Stack } from "@umriss-ui/core";
const SHIPMENTS = [
{ id: "SH-1042", customer: "Holloway Garden Supplies" },
{ id: "SH-1043", customer: "Marlow & Finch Books" },
{ id: "SH-1044", customer: "Oakridge Pharmacy" },
];
export default function DisabledUntilSelected() {
const [selected, setSelected] = useState<readonly string[]>([]);
const toggle = (id: string) =>
setSelected((now) => (now.includes(id) ? now.filter((one) => one !== id) : [...now, id]));
return (
<Stack gap={3} align="flex-start">
{SHIPMENTS.map((shipment) => (
<Checkbox
key={shipment.id}
label={`${shipment.id} · ${shipment.customer}`}
checked={selected.includes(shipment.id)}
onChange={() => toggle(shipment.id)}
/>
))}
<SplitButton
size="sm"
disabled={selected.length === 0}
onClick={() => setSelected([])}
menu={
<>
<MenuItem onSelect={() => setSelected([])}>Reschedule for tomorrow</MenuItem>
<MenuItem onSelect={() => setSelected([])}>Return to sender</MenuItem>
</>
}
>
{selected.length === 0 ? "Rebook" : `Rebook ${selected.length}`}
</SplitButton>
</Stack>
);
}
A group to page through the queue and a split button for the decision, as an approver's toolbar puts them.
import { useState } from "react";
import { Button, ButtonGroup, MenuItem, MenuSeparator, SplitButton, Stack, Text } from "@umriss-ui/core";
/* Data from the controlling world, written out here so the example runs on its own. */
const on = (month: number, day: number) => new Date(2026, month - 1, day).getTime();
interface InvoiceLine {
description: string;
quantity: number;
unitPrice: number;
/** A fraction: 0.1 is ten per cent off. */
discount: number;
/** A fraction: 0.19 or 0.07, or 0 where no VAT is charged. */
vatRate: number;
}
interface Invoice {
id: string;
supplier: string;
costCentre: string;
received: number;
due: number;
status: "awaiting approval" | "approved" | "paid" | "rejected";
lines: readonly InvoiceLine[];
}
const INVOICES: readonly Invoice[] = [
{
id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "CC-4400", received: on(3, 16), due: on(4, 15), status: "awaiting approval",
lines: [
{ description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9, discount: 0.1, vatRate: 0.19 },
{ description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5, discount: 0, vatRate: 0.19 },
{ description: "Delivery", quantity: 1, unitPrice: 35, discount: 0, vatRate: 0.19 },
],
},
{
id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "CC-1200", received: on(3, 13), due: on(4, 12), status: "awaiting approval",
lines: [
{ description: "Trade fair stand, 3 days", quantity: 1, unitPrice: 12_400, discount: 0.05, vatRate: 0.19 },
{ description: "Catering, per guest", quantity: 180, unitPrice: 18.5, discount: 0, vatRate: 0.07 },
],
},
{
id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "CC-4300", received: on(3, 9), due: on(4, 8), status: "approved",
lines: [
{ description: "Design tool licences, annual", quantity: 12, unitPrice: 540, discount: 0.15, vatRate: 0.19 },
{ description: "Onboarding session", quantity: 2, unitPrice: 890, discount: 0, vatRate: 0.19 },
],
},
{
id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "CC-4100", received: on(3, 2), due: on(3, 16), status: "paid",
lines: [{ description: "Contract review, hours", quantity: 14.5, unitPrice: 260, discount: 0, vatRate: 0.19 }],
},
{
id: "INV-26-0226", supplier: "Corrin Travel", costCentre: "CC-1100", received: on(2, 26), due: on(3, 12), status: "paid",
lines: [
{ description: "Rail tickets, sales conference", quantity: 22, unitPrice: 139, discount: 0, vatRate: 0.07 },
{ description: "Hotel, nights", quantity: 44, unitPrice: 112, discount: 0.08, vatRate: 0.07 },
],
},
{
id: "INV-26-0221", supplier: "Stellbrook Consulting", costCentre: "CC-2100", received: on(2, 21), due: on(3, 23), status: "rejected",
lines: [{ description: "Architecture review, days", quantity: 6, unitPrice: 1450, discount: 0, vatRate: 0 }],
},
];
const QUEUE = INVOICES.filter((invoice) => invoice.status === "awaiting approval");
export default function InvoiceToolbar() {
const [index, setIndex] = useState(0);
const [note, setNote] = useState("");
const invoice = QUEUE[index]!;
const decide = (what: string) => setNote(`${invoice.id}: ${what}`);
return (
<Stack gap={3}>
<Stack direction="row" gap={3} align="center" justify="space-between" wrap>
<Stack direction="row" gap={3} align="center" wrap>
<ButtonGroup aria-label="Invoices awaiting approval">
<Button size="sm" disabled={index === 0} onClick={() => setIndex(index - 1)}>
Previous
</Button>
<Button size="sm" disabled={index === QUEUE.length - 1} onClick={() => setIndex(index + 1)}>
Next
</Button>
</ButtonGroup>
<Text size="sm">
<Text as="span" size="sm" mono>
{invoice.id}
</Text>{" "}
· {invoice.supplier} · {index + 1} of {QUEUE.length}
</Text>
</Stack>
<SplitButton
size="sm"
variant="primary"
onClick={() => decide("approved")}
menu={
<>
<MenuItem onSelect={() => decide("approved with a comment")}>Approve with a comment</MenuItem>
<MenuItem onSelect={() => decide("sent back to the requester")}>Send back</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={() => decide("rejected")}>
Reject
</MenuItem>
</>
}
>
Approve
</SplitButton>
</Stack>
<Text size="xs" tone="muted">
{note === "" ? "No decision yet" : note}
</Text>
</Stack>
);
}
| Key | Action |
|---|---|
Tab |
Moves to the next button; on a split button, from the main action to the menu trigger. |
Enter / Space |
Presses the focused button; on the trigger, opens the menu and focuses its first entry. |
↓ / ↑ |
Moves through the open menu's entries. |
Home / End |
Jumps to the first or last entry. |
Escape |
Closes the menu and returns focus to the trigger. |
ButtonGroupProps| Name | Type | Default | Description |
|---|---|---|---|
size | ButtonSize"sm" | "md" | the size around it, else "md" | The size of the buttons in it that do not say their own - one group,
one height. Shown in: Variants and sizes |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Label of the group for the screen reader. Shown in: A group, Variants and sizes, Build an invoice toolbar and 2 more |
Also takes every attribute of <div>.
SplitButtonProps| Name | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | Label of the main action. Shown in: Offer variants of the main action, Disable while nothing is selected, Build an invoice toolbar |
menurequired | ReactNode | — | Entries of the menu - typically MenuItem.Shown in: Offer variants of the main action, Disable while nothing is selected, Build an invoice toolbar |
variant | ButtonVariant| "primary" | "secondary" | How loud the main action is; the trigger beside it follows suit. Shown in: Offer variants of the main action, Build an invoice toolbar |
size | ButtonSize"sm" | "md" | the size around it, else "md" | sm for buttons in header bars and table rows, md otherwise.Shown in: Disable while nothing is selected, Build an invoice toolbar |
menuLabel | string | the wording's "More actions" | Label of the trigger. |
align | "start" | "end" | "end" | Alignment of the menu relative to the trigger. Shown in: Offer variants of the main action |
loading | boolean | false | Shows a loading indicator and locks the button. from ButtonPropsShown in: Configurator (Button), Loading and disabled (Button), Submit a form (Button) and 4 more |
ref | Ref<HTMLButtonElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
onClick | ButtonProps["onClick"] | — | The main action. Shown in: Offer variants of the main action, Disable while nothing is selected, Build an invoice toolbar |
Also takes every attribute of <button>.
ButtonSizeA button's two heights - the controls' (ControlSize).
type ButtonSize = ControlSize;Resolves to "sm" | "md".
ButtonVariantHow loud a button is: primary for the one main action, secondary by
default, ghost quiet in the accent, plain quiet and neutral, danger
for a destructive action.
type ButtonVariant = "primary" | "secondary" | "ghost" | "plain" | "danger";ControlSizeThe two heights of a control: sm for a toolbar and dense forms, md
otherwise.
type ControlSize = "sm" | "md";What umriss deliberately does not build, and why: ADR-0032.