A list of actions that opens under a button (also called a dropdown menu). Reach for it when a place has more actions than room for buttons, or for the actions of each row in a list.
import { Menu, MenuItem, MenuSeparator } from "@umriss-ui/core";
The entries are things that happen, not values that stay; choosing one runs it and closes the menu so the result is in view.
An IconButton makes the trigger for a row; its label says what it acts on, such as “Actions for INC-1048”.
Give trigger a button and the entries as MenuItem; each runs its onSelect and the menu closes.
import { useState } from "react";
import { Button, Menu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
export default function Actions() {
const [last, setLast] = useState("-");
return (
<Stack gap={3} align="flex-start">
<Menu trigger={<Button size="sm">Actions</Button>}>
<MenuItem onSelect={() => setLast("Exported as CSV")}>Export as CSV</MenuItem>
<MenuItem onSelect={() => setLast("Link copied")}>Copy the link</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={() => setLast("View removed")}>
Remove the view
</MenuItem>
</Menu>
<Text size="xs" tone="muted">
Last: {last}
</Text>
</Stack>
);
}
For a trigger at the right of a header, align set to end lines the panel up with the trigger's right edge.
import { Button, Menu, MenuItem, Stack, Text } from "@umriss-ui/core";
export default function AlignEnd() {
return (
<Stack direction="row" justify="space-between" align="center" style={{ maxWidth: 520 }}>
<Text weight="medium">Budget 2026 · Marketing</Text>
<Menu align="end" trigger={<Button size="sm" variant="ghost">View</Button>}>
<MenuItem>Show forecast</MenuItem>
<MenuItem>Show last year</MenuItem>
<MenuItem>Show in thousands</MenuItem>
</Menu>
</Stack>
);
}
A disabled entry keeps its place and is skipped by the arrow keys; the danger tone marks what destroys, after a separator.
import { Button, Menu, MenuItem, MenuSeparator } from "@umriss-ui/core";
export default function DisabledAndDanger() {
return (
<Menu trigger={<Button size="sm">Invoice INV-26-0311</Button>}>
<MenuItem>Open</MenuItem>
<MenuItem disabled>Approve (already paid)</MenuItem>
<MenuItem>Download as PDF</MenuItem>
<MenuSeparator />
<MenuItem tone="danger">Cancel the invoice</MenuItem>
</Menu>
);
}
One menu per row keeps a list quiet; the trigger's name says which row it acts on, since every button reads the same.
import { useState } from "react";
import { Badge, IconButton, Menu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";
interface Incident {
id: string;
title: string;
severity: "SEV1" | "SEV2" | "SEV3";
state: "open" | "acknowledged" | "resolved";
}
const INCIDENTS: Incident[] = [
{ id: "INC-1048", title: "Checkout slow, card payments time out", severity: "SEV1", state: "acknowledged" },
{ id: "INC-1047", title: "Webhook deliveries delayed", severity: "SEV3", state: "open" },
{ id: "INC-1046", title: "Thumbnails missing for new uploads", severity: "SEV2", state: "resolved" },
];
const TONE = { SEV1: "danger", SEV2: "warning", SEV3: "neutral" } as const;
export default function PerRow() {
const [incidents, setIncidents] = useState(INCIDENTS);
const set = (id: string, state: Incident["state"]) =>
setIncidents((all) => all.map((one) => (one.id === id ? { ...one, state } : one)));
return (
<Stack gap={2} style={{ maxWidth: 620 }}>
{incidents.map((incident) => (
<Stack key={incident.id} direction="row" gap={3} align="center">
<Badge tone={TONE[incident.severity]}>{incident.severity}</Badge>
<Text size="sm" style={{ flex: 1 }}>
<Text as="span" size="sm" mono style={{ whiteSpace: "nowrap" }}>
{incident.id}
</Text>{" "}
{incident.title}
</Text>
<Text as="span" size="xs" tone="muted">
{incident.state}
</Text>
<Menu
align="end"
trigger={
<IconButton size="sm" aria-label={`Actions for ${incident.id}`}>
⋯
</IconButton>
}
>
<MenuItem disabled={incident.state !== "open"} onSelect={() => set(incident.id, "acknowledged")}>
Acknowledge
</MenuItem>
<MenuItem disabled={incident.state === "resolved"} onSelect={() => set(incident.id, "resolved")}>
Resolve
</MenuItem>
<MenuItem>Copy the link</MenuItem>
<MenuSeparator />
<MenuItem tone="danger" onSelect={() => setIncidents((all) => all.filter((one) => one.id !== incident.id))}>
Delete
</MenuItem>
</Menu>
</Stack>
))}
</Stack>
);
}
| Key | Action |
|---|---|
Enter or Space |
On the trigger, opens the menu and moves focus to the first entry; on an entry, runs it and closes the menu. |
↓ / ↑ |
Moves to the next or previous entry, wrapping at the ends; disabled entries are skipped. |
Home / End |
Moves to the first or last entry. |
Escape |
Closes the menu and returns focus to the trigger. |
Tab |
Closes the menu and moves on from the trigger. |
MenuProps| Name | Type | Default | Description |
|---|---|---|---|
triggerrequired | ReactElement<Record<string, unknown>> | — | Trigger, e.g. a <Button>; must accept refs. Its own ref keeps its
element: the menu holds the same one beside it. Shown in: Run actions from a trigger, Align with the right edge, Disable an entry, mark a destructive one and 4 more |
childrenrequired | ReactNode | — | The entries – typically MenuItem and MenuSeparator.Shown in: Run actions from a trigger, Align with the right edge, Disable an entry, mark a destructive one and 4 more |
align | "start" | "end" | "start" | Alignment of the panel relative to the trigger. Shown in: Align with the right edge, Act on a row, Build a toolbar (IconButton) and 1 more |
MenuItemProps| Name | Type | Default | Description |
|---|---|---|---|
tone | "default" | "danger" | "default" | "danger" for destructive actions. Shown in: Run actions from a trigger, Disable an entry, mark a destructive one, Act on a row and 5 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 |
| Name | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | What the entry does. The menu closes afterwards by itself: a menu that
stays open after something has happened hides the result. Shown in: Run actions from a trigger, Act on a row, Open overlays inside a full-screen panel (UmrissProvider) and 6 more |
Also takes every attribute of <button> – without onSelect.
What umriss deliberately does not build, and why: ADR-0032.