# Menu

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

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.

```ts
import { Menu, MenuItem, MenuSeparator } from "@umriss-ui/core";
```

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

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](https://romanhaendler.github.io/umriss-ui/core/iconbutton/) makes the trigger for a row; its label says what it acts on, such as “Actions for INC-1048”.

## Examples

### Run actions from a trigger

Give `trigger` a button and the entries as `MenuItem`; each runs its `onSelect` and the menu closes.

```tsx
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>
  );
}
```

### Align with the right edge

For a trigger at the right of a header, `align` set to `end` lines the panel up with the trigger's right edge.

```tsx
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>
  );
}
```

### Disable an entry, mark a destructive one

A `disabled` entry keeps its place and is skipped by the arrow keys; the `danger` tone marks what destroys, after a separator.

```tsx
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>
  );
}
```

### Act on a row

One menu per row keeps a list quiet; the trigger's name says which row it acts on, since every button reads the same.

```tsx
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>
  );
}
```

## When to use something else

- The user chooses a value that stays → Select
- The actions belong to a point or a row that is right-clicked → ContextMenu
- Two or three actions that fit as buttons → ButtonGroup
- Many commands across the application, found by typing → CommandPalette

## Keyboard

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

## API

### `MenuProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `trigger` *required* | `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.<br>Shown in: [Run actions from a trigger](https://romanhaendler.github.io/umriss-ui/core/menu/#actions), [Align with the right edge](https://romanhaendler.github.io/umriss-ui/core/menu/#align-end), [Disable an entry, mark a destructive one](https://romanhaendler.github.io/umriss-ui/core/menu/#disabled-and-danger) and 4 more |
| `children` *required* | `ReactNode` | — | The entries – typically `MenuItem` and `MenuSeparator`.<br>Shown in: [Run actions from a trigger](https://romanhaendler.github.io/umriss-ui/core/menu/#actions), [Align with the right edge](https://romanhaendler.github.io/umriss-ui/core/menu/#align-end), [Disable an entry, mark a destructive one](https://romanhaendler.github.io/umriss-ui/core/menu/#disabled-and-danger) and 4 more |
| `align` | `"start" \| "end"` | `"start"` | Alignment of the panel relative to the trigger.<br>Shown in: [Align with the right edge](https://romanhaendler.github.io/umriss-ui/core/menu/#align-end), [Act on a row](https://romanhaendler.github.io/umriss-ui/core/menu/#per-row), [Build a toolbar (IconButton)](https://romanhaendler.github.io/umriss-ui/core/iconbutton/#a-toolbar) and 1 more |

### `MenuItemProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `tone` | `"default" \| "danger"` | `"default"` | "danger" for destructive actions.<br>Shown in: [Run actions from a trigger](https://romanhaendler.github.io/umriss-ui/core/menu/#actions), [Disable an entry, mark a destructive one](https://romanhaendler.github.io/umriss-ui/core/menu/#disabled-and-danger), [Act on a row](https://romanhaendler.github.io/umriss-ui/core/menu/#per-row) and 5 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 |
|---|---|---|---|
| `onSelect` | `() => void` | — | What the entry does. The menu closes afterwards by itself: a menu that stays open after something has happened hides the result.<br>Shown in: [Run actions from a trigger](https://romanhaendler.github.io/umriss-ui/core/menu/#actions), [Act on a row](https://romanhaendler.github.io/umriss-ui/core/menu/#per-row), [Open overlays inside a full-screen panel (UmrissProvider)](https://romanhaendler.github.io/umriss-ui/core/umrissprovider/#portal-target) and 6 more |

Also takes every attribute of `<button>` – without `onSelect`.

## Known limits

- No submenus, no checkable entries and no type-ahead.

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