# ButtonGroup

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/buttongroup/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

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).

```ts
import { ButtonGroup, SplitButton } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/buttongroup/

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

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

- One option out of a few that stays chosen → RadioGroup
- Several views of one place → Tabs
- Many actions and none of them the main one → Menu

## 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`

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` | [`ButtonSize`](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#type-ButtonSize)<br>`"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.<br>Shown in: [Variants and sizes](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#variants-and-sizes) |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `aria-label` | `string` | — | Label of the group for the screen reader.<br>Shown in: [A group](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#group), [Variants and sizes](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#variants-and-sizes), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#invoice-toolbar) and 2 more |

Also takes every attribute of `<div>`.

### `SplitButtonProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `children` *required* | `ReactNode` | — | Label of the main action.<br>Shown in: [Offer variants of the main action](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#split-button), [Disable while nothing is selected](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#disabled-until-selected), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#invoice-toolbar) |
| `menu` *required* | `ReactNode` | — | Entries of the menu - typically `MenuItem`.<br>Shown in: [Offer variants of the main action](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#split-button), [Disable while nothing is selected](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#disabled-until-selected), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#invoice-toolbar) |
| `variant` | [`ButtonVariant`](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#type-ButtonVariant)<br>`"primary" \| "secondary" \| "ghost" \| "plain" \| "danger"` | `"secondary"` | How loud the main action is; the trigger beside it follows suit.<br>Shown in: [Offer variants of the main action](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#split-button), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#invoice-toolbar) |
| `size` | [`ButtonSize`](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#type-ButtonSize)<br>`"sm" \| "md"` | the size around it, else `"md"` | `sm` for buttons in header bars and table rows, `md` otherwise.<br>Shown in: [Disable while nothing is selected](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#disabled-until-selected), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#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.<br>Shown in: [Offer variants of the main action](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#split-button) |
| `loading` | `boolean` | `false` | Shows a loading indicator and locks the button. From `ButtonProps`.<br>Shown in: [Configurator (Button)](https://romanhaendler.github.io/umriss-ui/core/button/#configurator-button), [Loading and disabled (Button)](https://romanhaendler.github.io/umriss-ui/core/button/#loading-and-disabled), [Submit a form (Button)](https://romanhaendler.github.io/umriss-ui/core/button/#submit-a-form) and 4 more |
| `ref` | `Ref<HTMLButtonElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onClick` | [`ButtonProps`](https://romanhaendler.github.io/umriss-ui/core/button/#type-ButtonProps)`["onClick"]` | — | The main action.<br>Shown in: [Offer variants of the main action](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#split-button), [Disable while nothing is selected](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#disabled-until-selected), [Build an invoice toolbar](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#invoice-toolbar) |

Also takes every attribute of `<button>`.

### Types on this page

#### `ButtonSize`

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

```ts
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.

```ts
type ButtonVariant = "primary" | "secondary" | "ghost" | "plain" | "danger";
```

#### `ControlSize`

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

```ts
type ControlSize = "sm" | "md";
```

## Known limits

- No toggle or segmented state; a choice that stays is a radio group.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
