# Menu

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/menu/_

A menu button and its menu, on the platform's own Popover API: the top layer, light dismiss and focus return are the browser's, and a submenu is a popover nested inside its menu.

**Import**

```jsx
import Menu from '@box-kite/react/components/menu';
```

**Usage**

```jsx
<Menu trigger={(t) => <Button {...t}>Actions</Button>}>
  <Menu.Item onSelect={duplicate}>Duplicate</Menu.Item>
  <Menu.Item disabled>Move</Menu.Item>
  <Menu.Separator />
  <Menu.Sub label="Share">
    <Menu.Item onSelect={copyLink}>Copy link</Menu.Item>
    <Menu.Item onSelect={email}>Email</Menu.Item>
  </Menu.Sub>
</Menu>
```

## What the platform does, so this does not

A menu is a floating layer with a keyboard on it. The layer half is the browser's — this component is the Popover API plus APG's roles and keys, and every other library's portal, focus trap and `z-index` strategy is simply absent:

The top layer, and no portal

The menu paints above every stacking context and outside every clipped ancestor, while staying where it was declared — so it inherits the theme, the custom properties and the text direction around it, and the tab order runs trigger → menu with nothing to arrange.

Light dismiss, one layer per press

Escape and a press outside are the browser's, and Escape closes the _innermost_ menu first: a submenu, then the menu it came out of. Choosing an item closes the lot, which is the component's own doing.

A submenu is a nested popover

Its panel is declared beside its item, inside the menu it belongs to, which is what makes the two nest: opening a submenu leaves its parent open, a press inside it is a press inside the parent, and closing the menu closes every submenu with it.

What is left is the half APG asks for

The roles, `aria-checked`, the arrow keys, Home and End, typeahead, and the two focus moves the platform does not make: into the first item on open, and back onto a submenu's item when that submenu closes — the browser returns focus for the outermost layer only.

## The items a menu owns

A command is a `Menu.Item`; the two that carry a state are `Menu.CheckboxItem` and `Menu.RadioItem` inside a `Menu.RadioGroup`. A `Menu.Group` is a titled section — its `label` names the group through `aria-labelledby`, so the items are read as a set — and a `Menu.Separator` is the line between two of them.

A state does not close the menu, a command does

`Menu.Item` closes on select, because choosing a command is the end of the visit. A checkbox or a radio item does not, so several boxes can be ticked in one go — `closeOnSelect` is the prop that swaps either default.

```jsx
<Menu trigger={(t) => <Button {...t}>View</Button>}>
  <Menu.Group label="Rows">
    <Menu.CheckboxItem checked={compact} onCheckedChange={setCompact}>Compact rows</Menu.CheckboxItem>
  </Menu.Group>
  <Menu.Separator />
  <Menu.RadioGroup label="Sort by" value={sort} onValueChange={setSort}>
    <Menu.RadioItem value="name">Name</Menu.RadioItem>
    <Menu.RadioItem value="date">Date added</Menu.RadioItem>
  </Menu.RadioGroup>
</Menu>
```

## A disabled item is aria-disabled, and stays focusable

APG asks that a disabled menu item stay reachable, so that a keyboard user finds out it is there at all. The `disabled` prop therefore writes `aria-disabled` rather than the attribute, which would take the item out of the keyboard's reach and silence it: the arrows still land on it, the item announces itself as unavailable, and activating it does nothing.

## Submenus, and the arrow that opens them

`Menu.Sub` renders its own item — the label, the chevron, `aria-haspopup="menu"` and `aria-expanded` — and the menu beside it. It opens on the arrow pointing the way the text runs, on Enter or Space, and on hover; it closes on the arrow pointing back, on Escape, and whenever focus lands on another item, which is what makes hovering across the menu behave.

In a right-to-left menu the two arrows swap, because the key is the _reading order_ rather than the letter on it — and the chevron turns round with them, from one `rtl` rule in the style tree.

```jsx
<Menu trigger={(t) => <Button {...t}>Export</Button>}>
  <Menu.Item>Export as CSV</Menu.Item>
  <Menu.Sub label="Export as image">
    <Menu.Item>PNG</Menu.Item>
    <Menu.Item>SVG</Menu.Item>
    <Menu.Sub label="More">
      <Menu.Item>WebP</Menu.Item>
    </Menu.Sub>
  </Menu.Sub>
</Menu>
```

## Controlled, and the reason it changed

Leave `open` out and the menu owns its state. Pass it and you own it, with the asymmetry the platform imposes: a close cannot be refused, because the browser has already done it by the time `onOpenChange` runs.

Every change names its reason. `select` is an item being chosen — the one a consumer most often wants to tell apart — beside `trigger`, `escape`, `outside-pointer`, `tab` and `imperative`.

Last reason: `—`

## The menu is always rendered

Closed means `display: none`, not unmounted — the same shape as `<Popover>` and `<Dialog>`, and what lets the browser own showing and hiding. The entrance is `startingStyle` and the exit is `transitionBehavior="allow-discrete"`, both already in the component's styles, so there is no `<Presence>` anywhere near it.

The cost is that the items render whether or not anyone has opened the menu. Gate an expensive one yourself:

```jsx
const [open, setOpen] = useState(false);

<Menu open={open} onOpenChange={setOpen} trigger={(t) => <Button {...t}>Reports</Button>}>
  {open ? <Menu.Item>The expensive part</Menu.Item> : null}
</Menu>
```

## Where it goes

`side`, `align`, `offset` and `flip` are the four placement props every floating layer in the library takes, and `Menu.Sub` takes them too — its defaults being `side="end"` and `offset={0}`, so a submenu abuts the menu it came out of. The browser places both with CSS anchor positioning, so nothing is measured and no scroll listener exists.

One caveat the top layer costs, measured in Chrome 152: a menu **keeps the side it chose when it opened**, because Chrome never re-evaluates `position-try-fallbacks` for an element in the top layer. Every open picks the right side; only a scroll _while_ the menu is open can leave it hanging off the viewport, so close a menu if the page can scroll far underneath it.

## Styling

The menu and every part of it are Boxes, so every prop is available, and the defaults live in `Box.components('menu')` — with `menu.item`, `menu.group`, `menu.label`, `menu.separator`, and `menu.indicator`, `menu.check`, `menu.dot` and `menu.arrow` for the four marks a menu draws. All four are borders and a radius rather than an asset: this library ships no icons.

```jsx
<Menu p={2} borderRadius={3} minWidth={56} trigger={(t) => <Button {...t}>Actions</Button>}>
  <Menu.Item py={2.5} focus={{ bgColor: 'indigo-50' }}>Duplicate</Menu.Item>
</Menu>
```

## Menu props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `trigger`required | `(trigger: MenuTrigger) => React.ReactNode` | — | The trigger, handed the ref and props that wire it to the menu. It has to be a button. |
| `children` | `React.ReactNode` | — | The items: `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup`, `Menu.Group`, `Menu.Separator`, `Menu.Sub`. |
| `open` | `boolean` | — | Controlled open state. Leave it out and the menu owns it. |
| `defaultOpen` | `boolean` | `false` | Whether it starts open, when the menu owns its own state. |
| `onOpenChange` | `ChangeHandler<boolean, MenuReason>` | — | Fires with the new state and why it changed — `'select'`, `'trigger'`, `'escape'`, `'outside-pointer'`, `'tab'` or `'imperative'`. A close cannot be refused: the browser has already done it by then. |
| `label` | `string` | — | The menu's accessible name. Left out, it takes its trigger's, which is what APG asks for. |
| `labelledBy` | `string` | — | Names the menu after an element already on the page, instead of `label`. |
| `side` | `AnchorSide` | `'bottom'` | Which side of the trigger the menu sits on — `top`/`bottom` are the block axis, `start`/`end` the inline one, so a menu beside its trigger mirrors in a right-to-left page. Default `'bottom'`. |
| `align` | `AnchorAlign` | `'start'` | Which of the trigger's edges to line the menu up with along the other axis. Default `'start'`. |
| `offset` | `number` | `1` | The gap between trigger and menu, on the ÷4 spacing scale. Default 1 — 4px. |
| `flip` | `boolean` | `true` | Whether a side with no room may be swapped for its opposite. Default `true`. |
| `matchWidth` | `boolean` | `false` | Whether the menu is at least as wide as its trigger. Default `false` — a menu sizes to its content. |

### Menu.Item

One command in the menu. A `<button>`, so Enter and Space are the browser's.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The item's content. |
| `onSelect` | `?(event: React.MouseEvent): void;` | — | The item was chosen — by a press, or by Enter or Space, which the browser turns into one. |
| `disabled` | `boolean` | — | Focusable, announced, and not activatable. `aria-disabled` rather than the `disabled` attribute, because APG asks that a disabled item stay reachable: a keyboard user has to be able to find out it is there at all. |
| `closeOnSelect` | `boolean` | `true` | Whether choosing it closes the whole menu. Default `true`. |

### Menu.CheckboxItem

An item that carries a state of its own: `role="menuitemcheckbox"` and a tick.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `checked` | `boolean` | — | Controlled checked state. Leave it out and the item owns it. |
| `defaultChecked` | `boolean` | `false` | Whether it starts checked, when the item owns its own state. |
| `onCheckedChange` | `ChangeHandler<boolean, 'select'>` | — | Fires with the new state, and the press behind it. |
| `closeOnSelect` | `boolean` | `false` | Whether toggling it closes the menu. Default `false`, so several boxes can be ticked in one visit. |
| `children` | `React.ReactNode` | — | The item's content. |
| `disabled` | `boolean` | — | Focusable, announced, and not activatable. `aria-disabled` rather than the `disabled` attribute, because APG asks that a disabled item stay reachable: a keyboard user has to be able to find out it is there at all. |

### Menu.RadioGroup

A set of items of which one is chosen: `role="group"`, and `menuitemradio` inside it.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The radio items. |
| `label` | `React.ReactNode` | — | The group's heading. It names the group, so a screen reader reads the items as a set. |
| `value` | `string` | — | Controlled value — the `value` of whichever item is checked. |
| `defaultValue` | `string` | `''` | The value checked to start with, when the group owns its own state. |
| `onValueChange` | `ChangeHandler<string, 'select'>` | — | Fires with the value chosen, and the press behind it. |

### Menu.RadioItem

One of a radio group's choices. Checked when its `value` is the group's.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `value`required | `string` | — | What this item stands for, compared against the group's `value`. |
| `closeOnSelect` | `boolean` | `false` | Whether choosing it closes the menu. Default `false`, the way a checkbox item behaves. |
| `children` | `React.ReactNode` | — | The item's content. |
| `disabled` | `boolean` | — | Focusable, announced, and not activatable. `aria-disabled` rather than the `disabled` attribute, because APG asks that a disabled item stay reachable: a keyboard user has to be able to find out it is there at all. |

### Menu.Group

A titled section. The heading names the group through `aria-labelledby`, so the items inside are read as a set rather than as a run of unrelated commands.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The items in the section. |
| `label` | `React.ReactNode` | — | The section's heading. It names the group rather than standing alone: a heading nothing is labelled by is read as a stray line of text, which is why there is no separate label part. |

### Menu.Separator

The line between two sections. `role="separator"`, which a menu is allowed to own.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |

### Menu.Sub

A submenu: an item that opens a menu of its own. The panel is declared beside its item, *inside* the menu it belongs to, which is what makes it a nested popover — so opening it leaves the parent menu open and a press inside it is a press inside the parent (measured in Chrome 152).

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `label`required | `React.ReactNode` | — | The item's own content — what the submenu is called. |
| `children` | `React.ReactNode` | — | The submenu's items. |
| `disabled` | `boolean` | — | Focusable, announced, and it opens nothing. `aria-disabled`, for the reason `Menu.Item` gives. |
| `side` | `AnchorSide` | `'end'` | Which side of its item the submenu sits on. Default `'end'` — beside it, on the side the text runs towards. |
| `align` | `AnchorAlign` | `'start'` | Which of the item's edges to line the submenu up with. Default `'start'`. |
| `offset` | `number` | `0` | The gap between item and submenu, on the ÷4 spacing scale. Default 0 — a submenu abuts its menu. |
| `flip` | `boolean` | `true` | Whether a side with no room may be swapped for its opposite. Default `true`. |
| `itemProps` | `React.ButtonHTMLAttributes<HTMLButtonElement>` | — | Attributes for the item that opens it — the submenu's own go in `props`. |

## Menu keyboard

| Key | Result |
| --- | --- |
| `Enter / Space` | On the trigger, opens the menu with the first item focused. On an item, chooses it. On a submenu's item, opens the submenu. |
| `Down / Up` | On the trigger, opens the menu at its first or last item. Inside, moves between items and wraps at the ends. |
| `Home / End` | The first or the last item. |
| `Right / Left` | Opens the submenu of the item that has focus, and closes the submenu focus is in — the other way round in a right-to-left menu, which is APG's rule and the reading order's. |
| `Escape` | Closes the innermost menu and puts focus back on what opened it. |
| `Tab` | Closes the menu and moves focus on, as APG asks. |
| `A printable character` | Typeahead: jumps to the item whose text starts with what was typed, and the same letter again cycles through the items starting with it. |

## Menu accessibility

Implements [the WAI-ARIA APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).

- `role="menu"` on the panel, named by `label`, `labelledBy` or its trigger; `menuitem`, `menuitemcheckbox` and `menuitemradio` on the items, with `aria-checked` on the last two, `role="group"` around a titled section and `role="separator"` between them.
- The trigger carries `aria-haspopup="menu"`, `aria-expanded` and `aria-controls`; a submenu's item carries the same three, which is what says it opens another menu.
- A disabled item is `aria-disabled` and stays focusable, as APG asks — the `disabled` attribute would take it out of the keyboard's reach and silence it.

Swept with axe on every release, in this state: `Menu (open)`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

## Menu style tree

Every part the component draws is a node with a name, so a default can be restyled with `Box.components()` instead of a selector — and a variant is a name too.

`menu`variants: topLayer

`menu.item`variants: sub

`menu.indicator`

`menu.check`

`menu.dot`

`menu.arrow`

`menu.group`

`menu.label`

`menu.separator`

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

