# Button

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/button/>. 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).

Starts one action when pressed: save, send, deploy, delete. Its variant says how much weight the action carries beside its neighbours, and while the action runs it shows that it is busy and cannot be pressed twice.

```ts
import { Button } from "@umriss-ui/core";
```

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

Use the primary variant once per surface, and danger only for what cannot be undone. `ghost` is quiet in the accent, `plain` quiet and neutral.

A loading button locks itself and tells assistive technology it is busy; it needs no `disabled` beside it. A button is `type="button"` unless you set another type, so it never sends a form by accident.

## Examples

### One action

A label that says what happens, and `onClick` for the action.

```tsx
import { useState } from "react";
import { Button } from "@umriss-ui/core";

export default function OneAction() {
  const [acknowledged, setAcknowledged] = useState(false);
  return (
    <Button disabled={acknowledged} onClick={() => setAcknowledged(true)}>
      {acknowledged ? "Acknowledged" : "Acknowledge"}
    </Button>
  );
}
```

### Variants

Use `primary` once per surface – two main actions side by side are no longer a main action; `danger` only for what cannot be undone. `ghost` is quiet in the accent, `plain` quiet and neutral.

```tsx
import { Button, Stack } from "@umriss-ui/core";

export default function Variants() {
  return (
    <Stack direction="row" gap={3} wrap align="center">
      <Button variant="primary">Approve invoice</Button>
      <Button>Send back</Button>
      <Button variant="ghost">Add a note</Button>
      <Button variant="plain">Remind me later</Button>
      <Button variant="danger">Reject</Button>
    </Stack>
  );
}
```

### Sizes

Use `sm` in header bars and table rows, the default `md` everywhere else.

```tsx
import { Button, Stack } from "@umriss-ui/core";

export default function Sizes() {
  return (
    <Stack direction="row" gap={3} wrap align="center">
      <Button variant="primary">Plan tour</Button>
      <Button>Print labels</Button>
      <Button size="sm" variant="primary">
        Plan tour
      </Button>
      <Button size="sm">Print labels</Button>
    </Stack>
  );
}
```

### Loading and disabled

Set `loading` while the action runs – it locks the button by itself; `disabled` says the action is not possible here at all.

```tsx
import { useState } from "react";
import { Button, Stack } from "@umriss-ui/core";

export default function LoadingAndDisabled() {
  const [running, setRunning] = useState(false);

  const deploy = () => {
    setRunning(true);
    window.setTimeout(() => setRunning(false), 1200);
  };

  return (
    <Stack direction="row" gap={3} wrap align="center">
      <Button variant="primary" loading={running} onClick={deploy}>
        Deploy
      </Button>
      <Button loading>Rolling back</Button>
      <Button disabled>Promote to production</Button>
      <Button variant="primary" disabled>
        Deploy
      </Button>
    </Stack>
  );
}
```

### Submit a form

A button is `type="button"` unless you say otherwise; set `type="submit"` on the one that sends the form, so Enter in a field sends it too.

```tsx
import { useState } from "react";
import type { FormEvent } from "react";
import { Button, FormField, Input, Stack } from "@umriss-ui/core";

export default function SubmitAForm() {
  const [name, setName] = useState("");
  const [saving, setSaving] = useState(false);

  const submit = (event: FormEvent) => {
    event.preventDefault();
    setSaving(true);
    window.setTimeout(() => setSaving(false), 1200);
  };

  return (
    <form onSubmit={submit}>
      <Stack direction="row" gap={3} align="flex-end" wrap>
        <FormField label="Project name">
          <Input value={name} onChange={(event) => setName(event.target.value)} />
        </FormField>
        <Button type="submit" variant="primary" loading={saving} disabled={name.trim() === ""}>
          Create project
        </Button>
      </Stack>
    </form>
  );
}
```

### Add an icon

Put the icon before the label and hide it from screen readers; the button sets its size. A button with no label at all is an `IconButton`.

```tsx
import { Button, Stack } from "@umriss-ui/core";

function Download() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true">
      <path d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export default function WithAnIcon() {
  return (
    <Stack direction="row" gap={3} wrap align="center">
      <Button>
        <Download />
        Export CSV
      </Button>
      <Button size="sm" variant="ghost">
        <Download />
        Export CSV
      </Button>
    </Stack>
  );
}
```

### Build an approval bar

One primary action, the alternatives quieter beside it, the destructive one set apart, and every button busy while its request runs.

```tsx
import { useState } from "react";
import { Button, Card, CardBody, CardHeader, 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 Approval {
  invoice: string;
  step: "cost centre" | "finance";
  approver: string;
  decision: "approved" | "rejected" | "pending";
  at?: number;
  comment?: string;
}

/** Two signatures an invoice needs: its cost centre's owner, then finance. */
const APPROVALS: readonly Approval[] = [
  { invoice: "INV-26-0318", step: "cost centre", approver: "Olga Ivanova", decision: "pending" },
  { invoice: "INV-26-0317", step: "cost centre", approver: "Rafael Ortiz", decision: "approved", at: on(3, 16) },
  { invoice: "INV-26-0317", step: "finance", approver: "Martina Vogel", decision: "pending" },
  { invoice: "INV-26-0309", step: "cost centre", approver: "Kenji Arai", decision: "approved", at: on(3, 10) },
  { invoice: "INV-26-0309", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 11) },
  { invoice: "INV-26-0302", step: "cost centre", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
  { invoice: "INV-26-0302", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 3) },
  { invoice: "INV-26-0226", step: "cost centre", approver: "Helen Marsh", decision: "approved", at: on(2, 27) },
  { invoice: "INV-26-0226", step: "finance", approver: "Martina Vogel", decision: "approved", at: on(3, 2) },
  { invoice: "INV-26-0221", step: "cost centre", approver: "Anika Sørensen", decision: "rejected", at: on(2, 24), comment: "Not ordered - the review was cancelled in January." },
];

type Decision = "approve" | "return" | "reject";

const DONE: Record<Decision, string> = {
  approve: "Approved - passed on to finance.",
  return: "Sent back to the requester.",
  reject: "Rejected.",
};

export default function ApprovalBar() {
  const approval = APPROVALS[0]!;
  const [running, setRunning] = useState<Decision | null>(null);
  const [decided, setDecided] = useState<Decision | null>(null);

  const decide = (decision: Decision) => {
    setRunning(decision);
    window.setTimeout(() => {
      setRunning(null);
      setDecided(decision);
    }, 1000);
  };

  const busy = running !== null || decided !== null;

  return (
    <Card>
      <CardHeader eyebrow="Approval" title={`Invoice ${approval.invoice}`} />
      <CardBody>
        <Stack gap={4}>
          <Text size="sm" tone="secondary">
            {decided === null ? `Waiting for ${approval.approver}, ${approval.step} step` : DONE[decided]}
          </Text>
          <Stack direction="row" gap={3} wrap align="center">
            <Button variant="primary" loading={running === "approve"} disabled={busy} onClick={() => decide("approve")}>
              Approve
            </Button>
            <Button loading={running === "return"} disabled={busy} onClick={() => decide("return")}>
              Send back
            </Button>
            <span style={{ flex: 1 }} />
            <Button variant="danger" loading={running === "reject"} disabled={busy} onClick={() => decide("reject")}>
              Reject
            </Button>
          </Stack>
        </Stack>
      </CardBody>
    </Card>
  );
}
```

## When to use something else

- A button that shows only an icon → IconButton
- Several buttons that act on the same thing → ButtonGroup
- One main action with rarer variants → ButtonGroup
- Going to another page rather than doing something → Typography
- A setting that is on or off → Switch

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Reaches the button; a `disabled` or `loading` one is not a stop. |
| `Enter / Space` | Presses it. |

## Accessibility

A native `button`, named by its label. While `loading` it is disabled and `aria-busy`, and the spinner inside it is a `status` named "Loading"; the label stays, so the name does not change.

Under forced colours every variant keeps an outline where its edge would lie, and the primary one's is twice as wide, so it still stands out among its neighbours. With reduced motion a pressed button does not give way; only its colour changes.

## API

### `ButtonProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `variant` | [`ButtonVariant`](https://romanhaendler.github.io/umriss-ui/core/button/#type-ButtonVariant)<br>`"primary" \| "secondary" \| "ghost" \| "plain" \| "danger"` | `"secondary"` | How loud the button is. `primary` exactly once per surface - two main actions side by side are no longer a main action. `ghost` is quiet in the accent, `plain` quiet and neutral.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/button/#configurator-button), [Variants](https://romanhaendler.github.io/umriss-ui/core/button/#variants), [Sizes](https://romanhaendler.github.io/umriss-ui/core/button/#sizes) and 52 more |
| `size` | [`ButtonSize`](https://romanhaendler.github.io/umriss-ui/core/button/#type-ButtonSize)<br>`"sm" \| "md"` | the size of a `ControlSizeProvider` around it, else `"md"` | `sm` for buttons in header bars and table rows, `md` otherwise.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/button/#configurator-button), [Sizes](https://romanhaendler.github.io/umriss-ui/core/button/#sizes), [Add an icon](https://romanhaendler.github.io/umriss-ui/core/button/#with-an-icon) and 57 more |
| `loading` | `boolean` | `false` | Shows a loading indicator and locks the button.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/button/#configurator-button), [Loading and disabled](https://romanhaendler.github.io/umriss-ui/core/button/#loading-and-disabled), [Submit a form](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 |

Also takes every attribute of `<button>`.

### Types on this page

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

#### `ButtonSize`

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

```ts
type ButtonSize = ControlSize;
```

Resolves to `"sm" | "md"`.

#### `ControlSize`

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

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

## Known limits

- The label stays on one line; keep it short rather than let it wrap.
- No icon slot: an icon goes among the children, before the label, and the button sets its size.

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