Components / overlays
Menu
The id prefix seeds every item id from useId. A constant default meant two default-configured menus on one page emitted the same ids, and aria-activedescendant then named whichever the document happened to contain first.
Summary
A list of actions attached to a trigger.
| APG pattern | Menu Button |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
<Menu label="Project" items={[
{ label: 'Copy link', icon: <Icon path={ICONS.linkSimple} /> },
{ separator: true },
{ label: 'Delete project', icon: <Icon path={ICONS.trash} />, intent: 'negative' }
]} onSelect={(item) => run(item.label)} />
Anatomy
A trigger plus a floater carrying role="menu". Menubar renders data-se="menu" for its own floaters, so one set of rules dresses both.
| Part | Element | Required |
|---|---|---|
floater | <div> | yes |
icon | <svg> | yes |
item | <button> | yes |
label | <span> | yes |
separator | <div> | yes |
trigger | the caller's | the caller's |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
ref | React.Ref<HTMLButtonElement> | required | unknown |
type | 'button' | required | unknown |
onClick | () => void | required | unknown |
onKeyDown | (e: React.KeyboardEvent) => void | required | unknown |
States
| State | Expressed as | |
|---|---|---|
| Pressed | :active | |
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Disabled | aria-disabled | |
| Highlighted | data-highlighted | |
| Open or closed | data-state |
Behavior
Arrow keys, Home and End, Enter and Escape are wired. It dismisses on outside click and Escape, and returns focus to the trigger.
The trigger is the labeled outline button unless you hand one in. trigger receives the ref and the four attributes that make a button a menu button — aria-haspopup, aria-expanded, click and keys — and renders whatever the place needs: three dots beside a title, or a page title that is itself the menu. data-part="trigger" strips button chrome and draws the ring, so a title trigger reads as the heading it replaces.
Hover moves the highlight rather than painting a second state. Two highlights at once — one from the pointer, one from the keyboard — is a reader who cannot tell which will fire.
Accessibility
The highlight is drawn by the anatomy, because the browser cannot draw focus on an item the roving tabindex has not focused.
aria-haspopup and aria-expanded on the trigger say what it opens and whether it is open.
Keyboard
| Key | Result |
|---|---|
Arrow Down / Arrow Up | Move the highlight, wrapping at the ends |
Home / End | Jump to the first or last item |
Enter | Activates the highlighted item |
Escape | Closes and returns focus to the trigger |
Tab | Closes the menu and moves on |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-activedescendant | set at render |
aria-disabled | set at render |
aria-expanded | set at render |
aria-haspopup | always menu |
aria-hidden | always true |
aria-label | set at render |
role | always menu, always separator, always menuitem |
Focus
DOM focus stays on the panel and aria-activedescendant names the current item, which is why the highlight is drawn by the anatomy — the browser has nothing to ring. Focus returns to the trigger on close.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-a.test.jsxtest/apg/batch-e.test.jsxtest/apg/partials-sweep.test.jsxtest/browser/keyboard.spec.mjstest/browser/upstream-natives.spec.mjs
Known gaps
- 1 upstream ref — duplicate of button-aria-controls
- 1 upstream ref — duplicate of button-aria-expanded
- 1 upstream ref — duplicate of button-aria-haspopup in their other example
- 1 upstream ref — duplicate of button-down-arrow-or-space-or-enter
- 1 upstream ref — duplicate of button-up-arrow
- 1 upstream ref — duplicate of menu-character
- 1 upstream ref — duplicate of menu-down-arrow
- 1 upstream ref — duplicate of menu-end
- 1 upstream ref — duplicate of menu-enter
- 1 upstream ref — duplicate of menu-escape
- 1 upstream ref — duplicate of menu-home
- 1 upstream ref — duplicate of menu-up-arrow
- 1 upstream ref — APG's own page or markup, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-accent | intent |
--se-bg | background, border-color |
--se-border | background, border, border-color |
--se-border-width | border, border-width |
--se-control-h | block-size, inline-size, min-block-size |
--se-control-h-lg | block-size, inline-size |
--se-control-h-sm | block-size, inline-size |
--se-duration-fast | animation |
--se-duration-instant | transition |
--se-ease-decelerate | animation |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-offset | outline-offset |
--se-focus-width | outline |
--se-icon-md | block-size, inline-size |
--se-icon-sm | block-size, font-size, inline-size |
--se-layer-floating | z-index |
--se-leading-ui | line-height |
--se-mix-down | background, border-color |
--se-mix-hover | background, border-color |
--se-mix-line | border-color |
--se-mix-press | background |
--se-mix-shade | background, border-color |
--se-mix-soft | background |
--se-negative | background, color, intent |
--se-positive | intent |
--se-radius-lg | border-radius |
--se-radius-md | border-radius |
--se-radius-sm | border-radius |
--se-shadow-floating | box-shadow |
--se-space-1 | margin, padding, padding-block |
--se-space-2 | gap, inset-block-start, padding-inline |
--se-space-3 | padding-inline |
--se-space-4 | padding-inline |
--se-space-5 | padding-inline |
--se-surface | background |
--se-surface-highlight | background |
--se-surface-sunk | background |
--se-target-min | block-size, inline-size, min-block-size |
--se-text | color, intent |
--se-text-disabled | color |
--se-text-md | font-size |
--se-text-muted | color |
--se-text-on-intent | color |
--se-text-sm | font-size |
--se-text-xs | font-size |
Motion
The floater rises over --se-duration-fast.
Content
Each item names an action. Copy link, Delete project. A menu of nouns is a navigation list and belongs in a nav.
A destructive item sits last, after a separator.
Usage
A Menu is for actions. If the panel holds fields or prose, that is a Popover. If it holds a single explanation, that is a Tooltip.
A title as the trigger holds a selection, not an action. A groceries list "for Easter lunch" that opens the other events is choosing what the page is about. Give those items role="menuitemradio" through trigger's panel or keep them as actions that navigate — either is honest; a select in a heading is not.
Not a Select. Menu, Select and Combobox are three components and one temptation: a menu fires an action and forgets; a select holds a value.
When not to use it. For a menu bar across the top of an application — that is Menubar, which has a different arrow contract.
Notes
Hand-written, not Radix. Radix is permitted for six patterns and a menu button is not one of them any more — the roving highlight here is aria-activedescendant with DOM focus parked on the panel, which is why the highlight is the anatomy's job rather than a focus ring.
The id prefix seeds every item id from useId. A constant default meant two default-configured menus on one page emitted the same ids, and aria-activedescendant then named whichever the document happened to contain first.