ButtonGroup

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.

Examples

A group

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>
  );
}

Variants and sizes

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>
  );
}

Offer variants of the main action

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>
  );
}

Disable while nothing is selected

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>
  );
}

Build an invoice toolbar

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>
  );
}

When to use something else

Keyboard

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.

API

ButtonGroupProps

NameTypeDefaultDescription
sizeButtonSize
"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

refRef<HTMLDivElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Accessibility

NameTypeDefaultDescription
aria-labelstring—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

NameTypeDefaultDescription
childrenrequiredReactNode—Label of the main action.

Shown in: Offer variants of the main action, Disable while nothing is selected, Build an invoice toolbar

menurequiredReactNode—Entries of the menu - typically MenuItem.

Shown in: Offer variants of the main action, Disable while nothing is selected, Build an invoice toolbar

variantButtonVariant
| "primary"
| "secondary"
| "ghost"
| "plain"
| "danger"
"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

sizeButtonSize
"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

menuLabelstringthe 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

loadingbooleanfalseShows a loading indicator and locks the button. from ButtonProps

Shown in: Configurator (Button), Loading and disabled (Button), Submit a form (Button) and 4 more

refRef<HTMLButtonElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

Also takes every attribute of <button>.

Types on this page

ButtonSize

A button's two heights - the controls' (ControlSize).

type ButtonSize = ControlSize;

Resolves to "sm" | "md".

ButtonVariant

How 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";

ControlSize

The two heights of a control: sm for a toolbar and dense forms, md otherwise.

type ControlSize = "sm" | "md";

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index