Button

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.

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

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.

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.

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.

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.

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.

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.

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.

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

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

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

Shown in: Configurator, Variants, Sizes and 52 more

sizeButtonSize
"sm" | "md"
the size of a ControlSizeProvider around it, else "md"sm for buttons in header bars and table rows, md otherwise.

Shown in: Configurator, Sizes, Add an icon and 57 more

loadingbooleanfalseShows a loading indicator and locks the button.

Shown in: Configurator, Loading and disabled, Submit a form 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

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.

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

ButtonSize

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

type ButtonSize = ControlSize;

Resolves to "sm" | "md".

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