Menu
Radix dropdown-menu primitive set (Menu, Trigger, Content, Item, Sub) shared by Avatar, SplitButton, Breadcrumb and FieldShell.
Status: stable. Group: overlay. Markdown version: /design-system/components/menu.md
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
alignvalues:start,center,end(defaultstart). - Allowed
sidevalues: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/navigationexports it too. - Styles: import
@digitaltableteur/react/style.cssonce 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)
- Markdown version of this page
- Component index as JSON
- All 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.