# Menu

> Radix dropdown-menu primitive set (Menu, Trigger, Content, Item, Sub) shared by Avatar, SplitButton, Breadcrumb and FieldShell.

- Status: stable
- Group: overlay
- Page: https://www.digitaltableteur.com/design-system/components/menu
- Contract: https://www.digitaltableteur.com/contracts/v1/Menu.contract.json

## When to use

Menu is the shared dropdown-menu primitive: a focusable trigger opens a portaled list of actions. It is built on @radix-ui/react-dropdown-menu, so roving focus, arrow-key navigation, typeahead, Escape, focus return and collision-aware positioning all come from Radix; this layer owns only the visual treatment. Compose it from parts: wrap your control in MenuTrigger asChild, list MenuItems inside MenuContent, group with MenuSeparator and nest a MenuSub for second-level menus. Items take a leading icon (rendered in a fixed gutter so labels align), end-aligned meta content (the deprecated trailing prop maps onto meta), an optional trailingIcon, a selected check indicator, tone="destructive" for deletions, a disabled flag and either an onSelect handler or an href to render as a link. Setting selected (true or false) makes the item a checkable choice: it renders as role=menuitemcheckbox with a matching aria-checked, so the state the check draws is announced too; a selected href item stays a link marked aria-current=true. It is the dogfooded engine behind Avatar's account menu, SplitButton's options, Breadcrumb's collapsed items and FieldShell's field action menus; NavMenuList stays a navigation list, not a menu.

## When not to use

- Do not reach for Menu to build primary site navigation; navigation is a list of links (NavMenuList), not an actions menu; forcing role=menu onto nav is an a11y regression.
- Do not put a Menu trigger inside another interactive control; give the menu its own focusable trigger via MenuTrigger asChild.
- Do not hand-roll dropdown positioning or keyboard handling around Menu; Radix owns collision, roving focus, typeahead, and Escape.
- Do not use Menu for primary navigation; a list of page links belongs in NavMenuList with aria-current, not a role=menu.

## Look-alikes

- NavMenuList: Do not reach for Menu to build primary site navigation; navigation is a list of links (NavMenuList), not an actions menu; forcing role=menu onto nav is an a11y regression.

## Props

| Prop | Type | Default | Description | Required |
| --- | --- | --- | --- | --- |
| `children` | `React.ReactNode` | none | No description in the contract. | Yes |
| `className` | `string` | none | Extra class on the content surface. | No |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | none | Preferred side relative to the trigger; flips when out of room (fixed collisionPadding of 12px). Defaults to "bottom". | No |
| `align` | `"center" \| "start" \| "end"` | `start` | Alignment along the trigger edge. Defaults to "start". | No |
| `sideOffset` | `number` | none | Gap in px between the trigger and the content. Defaults to 6. | No |
| `alignOffset` | `number` | none | Shift in px along the alignment edge (negative moves toward the start). Defaults to 0. | No |

## Variants

- Allowed `align` values: `start`, `center`, `end` (default `start`).
- Allowed `side` values: `top`, `right`, `bottom`, `left`.

## Invalid combinations

The contract records no invalid combinations.

## Keyboard

| Key | Behaviour |
| --- | --- |
| ArrowDown | Moves focus or the value in that direction. (standard) |
| ArrowUp | Moves focus or the value in that direction. (standard) |
| Home | Moves to the first item. (standard) |
| End | Moves to the last item. (standard) |
| Enter | Activates the focused control. (standard) |
| Escape | Closes or dismisses the open layer. (standard) |
| ArrowRight | Moves focus or the value in that direction. (standard) |
| ArrowLeft | Moves focus or the value in that direction. (standard) |
| Tab | Moves focus to the control and on to the next focusable element. (standard) |

Rows marked (standard) name a key the contract lists without a description; the behaviour shown is the usual one for that key.

## Rules

Every item under When not to use is a MUST NOT. MUST and SHOULD follow RFC 2119.

| Level | Rule | How it is checked |
| --- | --- | --- |
| MUST | ARIA: Trigger exposes aria-haspopup=menu and aria-expanded (Radix) | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Content is role=menu; items are role=menuitem with roving tabindex, or role=menuitemcheckbox when selected is set. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: ArrowUp/Down move between items, Home/End jump, typeahead focuses by label. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Escape closes and returns focus to the trigger; Tab closes (non-modal menu-button pattern) | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Submenus open with ArrowRight/Enter and close with ArrowLeft/Escape. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Disabled items are announced disabled and are not selectable. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: MenuItem with selected set (true or false) is role=menuitemcheckbox with a matching aria-checked; the drawn check stays aria-hidden because aria-checked carries the state. A selected href item stays a link and carries aria-current=true. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | Required stories produce no axe violations at the configured severity. | Automated by `npm run test:stories:matrix:ci`. WCAG 2.5.8. |
| MUST | The accessibility tree (role, name, state) matches the committed snapshot for every required story. | Automated by `npm run test:stories:matrix:ci`. WCAG 1.3.1, 4.1.2. |
| MUST | The component remains legible and operable under a real browser forced-colors: active pass. | Automated by `npm run test:stories:hc`. |
| MUST | Every documented keyboard interaction works as specified. | Not yet verified: no evidence record. WCAG 2.1.1, 2.1.2. |
| MUST | A human completed an end-to-end accessibility review of this component. | Manual review, last on 2026-11-04. |
| SHOULD | Wrap your real Button or Avatar in MenuTrigger asChild so it receives the trigger wiring instead of a nested extra button. | Not stated in the contract. |
| SHOULD | Give each MenuItem a short verb label; put the icon in the icon slot so labels align in a column regardless of glyph width. | Not stated in the contract. |
| SHOULD | Use href for navigation items (renders an anchor, so open-in-new-tab and middle-click work) and onSelect for actions. | Not stated in the contract. |
| SHOULD | Group related actions with MenuSeparator and reserve MenuSub for genuinely nested choices; a flat menu is faster to scan. | Not stated in the contract. |
| SHOULD NOT | Do not re-implement positioning or keyboard handling around Menu; Radix already provides collision, roving focus, typeahead, and Escape. | Not stated in the contract. |

## Install and import

- Install: `npm install @digitaltableteur/react` (version 0.9.1, restricted access on npm).
- Import: `import { Menu } from "@digitaltableteur/react";`
- Entry point: `@digitaltableteur/react/navigation` exports it too.
- Styles: import `@digitaltableteur/react/style.css` once at the app boundary.
- Also available as the custom element `<dt-menu>`; see the custom elements manifest.

## Links

- Machine-readable contract (Design System Contract 1.0): https://www.digitaltableteur.com/contracts/v1/Menu.contract.json
- Markdown version of this page: https://www.digitaltableteur.com/design-system/components/menu.md
- Component index as JSON: https://www.digitaltableteur.com/design-system/components.json
- All components: https://www.digitaltableteur.com/design-system/components
- Source: The implementation lives in a private repository. This page, its Markdown twin and the contract are generated from it at build time and are the public source of truth.
