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.
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
| Key | Action |
|---|---|
Tab |
Reaches the button; a disabled or loading one is not a stop. |
Enter / Space |
Presses it. |
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.
ButtonProps| Name | Type | Default | Description |
|---|---|---|---|
variant | ButtonVariant| "primary" | "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 |
size | ButtonSize"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 |
loading | boolean | false | Shows a loading indicator and locks the button. Shown in: Configurator, Loading and disabled, Submit a form 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 |
Also takes every attribute of <button>.
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";ButtonSizeA button's two heights - the controls' (ControlSize).
type ButtonSize = ControlSize;Resolves to "sm" | "md".
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.