Dropdown
A button that opens a menu. The surface is Popover — the same top layer, the same anchor positioning, the same flat bordered surface — and on top of it go role=menu, real DOM focus travelling row to row, and the keyboard the ARIA Authoring Practices describe, typeahead included. The rows are the library's shared menu row: a Dropdown row, a ComboBox option and a styled select picker row are the same object at every size.
- Stable
- React 19
- APG menu keyboard
- Built on Popover
A heading, three commands, a rule and one destructive row. Every menu on this page is drawn open, on the in-flow path.
Code
const ITEMS: DropdownItem[] = [{ kind: 'group', label: 'Document' },{ kind: 'item', label: 'Open', icon: <FolderIcon />, shortcut: '⌘O', onSelect: openDoc },{ kind: 'item', label: 'Share', icon: <ShareIcon />, shortcut: '⇧⌘S', onSelect: share },{ kind: 'item', label: 'Archive', shortcut: '⌘E', disabled: true, onSelect: archive },{ kind: 'separator' },{ kind: 'item', label: 'Delete', tone: 'danger', shortcut: '⌫', onSelect: remove },];
<Dropdown items={ITEMS} trigger={<Button>Actions</Button>} /><div style="padding-block-end: 12rem"> <div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md"> <button type="button" id="usage-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="usage-m">Actions</button> <div id="usage-m" role="menu" aria-labelledby="usage-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <div role="presentation" class="wayra-dropdown-group">Document</div> <button type="button" role="menuitem" tabindex="0" data-active="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button> <button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button> <button type="button" role="menuitem" tabindex="-1" aria-disabled="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Archive</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘E</span></button> <hr class="wayra-dropdown-separator"> <button type="button" role="menuitem" tabindex="-1" data-tone="danger" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Delete</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌫</span></button> </div> </div></div>Two required props and nothing else. items is data, not children: the rows are
a discriminated union walked by the arrow keys and by typeahead, and index math
over an array is what makes both of those a handful of pure functions instead of
a tree walk. trigger is a single element — usually a
Button — cloned with aria-haspopup, aria-expanded,
aria-controls, an id and the toggle handler. Its own props and its own
onClick survive the cloning.
The root is one box holding exactly two children, the trigger and the surface,
and it wears wayra-popover-anchor as a co-class — it is the anchor
container Popover requires.
Playground
Section titled “Playground”Change the props and copy the code.
last selected: nothing yet
Generated code
const ITEMS: DropdownItem[] = [
{ kind: 'group', label: 'Document' },
{ kind: 'item', label: 'Open', icon: <FolderIcon />, shortcut: '⌘O', onSelect: open },
{ kind: 'item', label: 'Share', icon: <ShareIcon />, shortcut: '⇧⌘S', onSelect: share },
{ kind: 'item', label: 'Archive', shortcut: '⌘E', disabled: true, onSelect: archive },
{ kind: 'separator' },
{ kind: 'item', label: 'Delete', tone: 'danger', shortcut: '⌫', onSelect: remove },
];
<Dropdown
items={ITEMS}
trigger={<Button>Actions</Button>}
/>The four kinds of row
Section titled “The four kinds of row”Code
const ITEMS: DropdownItem[] = [{ kind: 'group', label: 'View' },{ kind: 'checkbox', label: 'Sidebar', checked: sidebar, onCheckedChange: setSidebar },{ kind: 'checkbox', label: 'Ruler', checked: ruler, onCheckedChange: setRuler },{ kind: 'separator' },{ kind: 'item', label: 'Reset', onSelect: reset },];<div style="padding-block-end: 12rem"> <div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md"> <button type="button" id="kinds-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="kinds-m">View</button> <div id="kinds-m" role="menu" aria-labelledby="kinds-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <div role="presentation" class="wayra-dropdown-group">View</div> <button type="button" role="menuitemcheckbox" aria-checked="true" tabindex="-1" data-checked="true" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Sidebar</span></button> <button type="button" role="menuitemcheckbox" aria-checked="false" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Ruler</span></button> <hr class="wayra-dropdown-separator"> <button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"></span><span class="wayra-dropdown-label">Reset</span></button> </div> </div></div>kind |
Element | What it does |
|---|---|---|
item |
<button role="menuitem"> |
Runs onSelect, then closes the menu and hands focus back to the trigger. |
checkbox |
<button role="menuitemcheckbox"> |
Reports the new state through onCheckedChange and leaves the menu open. |
separator |
<hr> |
A rule between two runs. Two in a row collapse to one. |
group |
<div role="presentation"> |
A heading over the rows that follow. |
A checkable row keeps the menu open on purpose: a View menu lets several
boxes be ticked in one visit, and closing after each one would make that four
round trips. The state is the consumer’s — checked is required and there is no
uncontrolled mode.
shown: Sidebar
Code
import { useState } from "react";import type { DropdownItem } from "wayra-ui";import { Button, Dropdown } from "wayra-ui";
const PANELS = ["Sidebar", "Ruler", "Grid"] as const;
export function CheckableDemo() { const [shown, setShown] = useState<readonly string[]>(["Sidebar"]);
function toggle(panel: string, checked: boolean) { setShown((current) => checked ? [...current, panel] : current.filter((one) => one !== panel), ); }
// A checkbox row is controlled by the consumer, like every checkbox in the // library: the component reports the new state and never keeps one. const items: DropdownItem[] = [ { kind: "group", label: "View" }, ...PANELS.map((panel) => ({ kind: "checkbox" as const, label: panel, checked: shown.includes(panel), onCheckedChange: (checked: boolean) => { toggle(panel, checked); }, })), { kind: "separator" }, { kind: "item", label: "Reset", onSelect: () => { setShown(["Sidebar"]); }, }, ];
return ( <div className="hu-demo-col"> <Dropdown items={items} trigger={<Button variant="outline">View</Button>} /> <p className="hu-readout"> shown: {shown.length === 0 ? "nothing" : shown.join(", ")} </p> </div> );}The heading is role="presentation" rather than a real role="group": a
role="menu" admits menu rows, groups and separators as children, and a bare
heading is none of the three. A real group nests the rows inside it, which
ComboBox already does with the APG grouped listbox,
building each group from options just as flat as these items; Dropdown has not
taken that step yet, so its rows stay direct children of the menu. The separator is an <hr>
because that element already means separator, drawn edge to edge with a negative
inline margin that is exactly the surface’s own padding.
The three columns of a row
Section titled “The three columns of a row”Every row is a gutter, a label and a shortcut column, and the gutter is what keeps every label starting on the same x whether the row carries an icon, a check mark, or nothing.
labelis astring, not aReactNode. It is what typeahead matches and what the row truncates, and both need real text. A long label ends in an ellipsis and never wraps — a menu row truncates, it does not reflow.iconandshortcutare decorative. Both slots arearia-hidden: the icon repeats the label, and the shortcut names a key the row already activates. Nothing in either is announced.- The gutter is
1em, not the check’s0.7em. It holds a glyph as well, and the check keeps its shared size inside it — which is why a checkable row and an icon row line their labels up exactly.
Tone, disabled, and the highlight
Section titled “Tone, disabled, and the highlight”tone: 'danger' is for the one destructive row a menu usually ends with. It does
not paint a colour; it re-points the accent pair to the error role, so the
row’s text, its highlight fill and that highlight’s text colour all move
together — and error clears 4.5:1 on base-100 by the library’s two-token
colour contract.
A disabled row is marked with aria-disabled and left in place, never removed:
it is still announced, and what skips it is the arrow keys and typeahead. It is
greyed, not faded — the muted colour measures 5.64:1, where the usual 0.4
disabled opacity over this surface would not.
The highlight is a solid fill sweeping the whole row, not a wash, and it follows
the keyboard and the pointer through one data-active attribute. It is also why
this component is a documented exception to the library’s single focus-ring rule:
the row takes real DOM focus, and a 3.5px halo inside a menu would sit on top
of the row above it. The fill keys on :focus-visible as well as on
[data-active], so the indicator survives even if the two ever drift apart.
A press goes one step past the highlight while the pointer is down: the role mixed 88%
toward base-content, the page’s text colour. Toward black a dark -content label would
fall to 3.38:1 in three of the themes that promise AA; toward the text colour every one of
them keeps at least 5.18:1. Under forced colors a pressed row is Highlight, like the
active one.
Code
<Dropdown size="sm" items={ITEMS} trigger={<Button size="sm">sm</Button>} /><Dropdown items={ITEMS} trigger={<Button>md</Button>} /><Dropdown size="lg" items={ITEMS} trigger={<Button size="lg">lg</Button>} /><div style="padding-block-end: 7rem"> <div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="sm"> <button type="button" id="sz-sm-t" class="wayra-button" data-variant="filled" data-size="sm" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-sm-m">sm</button> <div id="sz-sm-m" role="menu" aria-labelledby="sz-sm-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button> <button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button> </div> </div></div><div style="padding-block-end: 7rem"> <div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md"> <button type="button" id="sz-md-t" class="wayra-button" data-variant="filled" data-size="md" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-md-m">md</button> <div id="sz-md-m" role="menu" aria-labelledby="sz-md-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button> <button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button> </div> </div></div><div style="padding-block-end: 7rem"> <div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="lg"> <button type="button" id="sz-lg-t" class="wayra-button" data-variant="filled" data-size="lg" aria-haspopup="menu" aria-expanded="true" aria-controls="sz-lg-m">lg</button> <div id="sz-lg-m" role="menu" aria-labelledby="sz-lg-t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <button type="button" role="menuitem" tabindex="0" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg></span><span class="wayra-dropdown-label">Open</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span></button> <button type="button" role="menuitem" tabindex="-1" class="wayra-dropdown-item"><span aria-hidden="true" class="wayra-dropdown-lead"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M12 4 8 8M12 4l4 4"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg></span><span class="wayra-dropdown-label">Share</span><span aria-hidden="true" class="wayra-dropdown-shortcut">⇧⌘S</span></button> </div> </div></div>Three steps, not the shared five: a menu row is text with two gutters, and xs
and xl fall outside what a menu should ever be. Each step measures its rows
against the same field ladder a Button and an Input of that step use, so a menu
never looks like it came from a different set — and the menu’s own corner is
concentric with the trigger’s, because the surface radius is the control’s
radius plus the surface’s padding. Every row clears WCAG 2.5.8’s 24px target
floor; at the small step the floor is what wins, and everywhere else the ladder
is already above it.
Placement, width and the surface
Section titled “Placement, width and the surface”placement, align and matchAnchorWidth pass straight through to
Popover and behave exactly as documented there — with one
changed default: matchAnchorWidth is true here. That is the library’s rule
that a menu is as wide as the control it belongs to, and a menu is the case the rule
was written for. Everything else about the box — the top layer, the anchor
positioning, the flip when there is no room, the light dismiss (below the
top-layer tier, Popover’s own close on a press outside the Dropdown root), the
opaque bordered surface, the capped height that turns a long menu into a
scrolling region — is Popover’s, unchanged. So is placement="cover", which
lays the menu over the trigger itself; Fab’s flower is
the layout it exists for.
Open state, and where focus goes
Section titled “Open state, and where focus goes”<Dropdown open={open} onOpenChange={setOpen} items={ITEMS} trigger={<Button>Actions</Button>} />open makes React the owner, defaultOpen seeds the uncontrolled state, and
onOpenChange fires for every change including the ones the browser started on
its own — light dismiss and its own Escape. Focus is where the two paths differ:
- Opening from
openordefaultOpenmoves no focus. Nothing user-initiated happened, so nothing is stolen; the keyboard starts when the user tabs to a row. - Opening from the trigger does. A click lands on the first selectable row,
ArrowDownon the first,ArrowUpon the last. - Closing on purpose returns focus to the trigger. Escape, Tab and selecting a row all do it. A close the browser started does not — there is nothing to return.
A closed menu still holds exactly one tab stop, on the row that would take focus first, which is what keeps a scrollable menu from being a focusable region with nothing focusable inside it.
Keyboard
Section titled “Keyboard”DOM focus really moves onto the rows — a roving tabindex, the same mechanism
Tabs uses — rather than travelling through
aria-activedescendant: a menu row is a real <button>, and Enter has to
reach it.
| Key | Behavior |
|---|---|
Enter / Space on the trigger |
Opens the menu on the first selectable row. |
↓ on the trigger |
Opens on the first selectable row. |
↑ on the trigger |
Opens on the last selectable row. |
↓ / ↑ in the menu |
Next or previous selectable row. Wraps at both ends; steps over separators, headings and disabled rows. |
Home / End |
The first and the last selectable row. |
Enter / Space on a row |
Activates it. An item runs and closes; a checkbox toggles and stays. |
| Printable characters | Typeahead. See below. |
Escape |
Closes the menu and puts focus back on the trigger. |
Tab / Shift+Tab |
Closes the menu, puts focus back on the trigger, and does not call preventDefault — so the browser’s own Tab carries on from there to the next control. |
Typeahead has four rules, all of them borrowed from a native menu:
- A 500ms buffer. A key inside the window lengthens the query; one after it starts a new one.
- One character cycles, more than one refines. A single letter starts the search at the row after the current one, so pressing it again walks through every row beginning with it; a longer query starts at the current row, so refining a query that already matched keeps its match.
- Accents fold.
afinds Ánimo — the library’s default matching pipeline, not a special case here. - A modifier makes it a command.
⌘Ois Open, not the lettero, so a modified keystroke is left to the browser untouched.
Hand-written markup
Section titled “Hand-written markup”The class plus the data-* attributes is the public API, and every menu on this
page is written against it with no React involved:
<div class="wayra-dropdown wayra-popover-anchor" data-color="primary" data-size="md"> <button type="button" id="t" class="wayra-button" aria-haspopup="menu" aria-expanded="true" aria-controls="m">Actions</button> <div id="m" role="menu" aria-labelledby="t" data-open="true" class="wayra-dropdown-menu wayra-popover wayra-overlay"> <div role="presentation" class="wayra-dropdown-group">Document</div> <button type="button" role="menuitem" tabindex="0" data-active="true" class="wayra-dropdown-item"> <span aria-hidden="true" class="wayra-dropdown-lead"></span> <span class="wayra-dropdown-label">Open</span> <span aria-hidden="true" class="wayra-dropdown-shortcut">⌘O</span> </button> <hr class="wayra-dropdown-separator"> </div></div>The component also writes data-open on the root while the menu is open, for
consumer CSS; no library rule reads it. Each row carries --wayra-dropdown-index
and the menu --wayra-dropdown-count, the numbers a layout that spreads the
rows out computes from — Fab is the one that does.
What hand-written markup does not get is the keyboard: the roving tabindex, typeahead, Escape and the focus return are the component’s, and they are the whole reason it exists. Reproduce the paint by hand, never the behaviour.
What’s not included
Section titled “What’s not included”It is not navigation
Section titled “It is not navigation”role="menu" means a menu of commands — the application menu bar model, where
arrow keys move and the whole widget is one tab stop. A list of links to other
pages is not that: it is a <nav> around anchors, where every link is its own
tab stop and a screen reader can list them. Dressing navigation as a menu takes
both away.
No submenus, no radio rows, no children
Section titled “No submenus, no radio rows, no children”submenu is a reserved name on the type, not a prop — declared never, so
passing one fails typecheck rather than being silently dropped. A nested menu
needs its own surface, its own open timing and a second keyboard axis; it is a
later phase. The union has four kinds and menuitemradio is not one of them.
Nor is there a compound API (Dropdown.Item and friends): the arrow keys,
Home/End and typeahead are index math over an array, and children would mean
walking a tree to answer the same question.
API reference
Section titled “API reference”DropdownProps extends ComponentProps<"div"> minus color and children,
so id, style, ref, className and every data-* land on the root box.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
readonly DropdownItem[] |
— required | The rows, in order. A menu with no rows has nothing to open. |
trigger |
ReactElement |
— required | The button that opens the menu, as a single element. Cloned with the ARIA wiring and the toggle handler; its own props and handler survive. |
color |
WayraColor |
"primary" |
Semantic role of the highlight that sweeps the active row. A danger row re-points it to error for itself. |
size |
"sm" | "md" | "lg" |
"md" |
Size step of the rows. Three steps, not five. |
placement |
"block-end" | "block-start" | "cover" |
"block-end" |
Which side of the trigger the menu opens on, or cover to lay it over the trigger. Passed to Popover. |
align |
"start" | "center" | "end" |
"start" |
Which edge it lines up with, or center. Passed to Popover. |
matchAnchorWidth |
boolean |
true |
Grow the menu to at least the trigger’s width. On by default here, unlike the bare Popover. |
open |
boolean |
— | Controlled. Opening from here moves no focus. |
defaultOpen |
boolean |
false |
Uncontrolled first render. Moves no focus either. |
onOpenChange |
(open: boolean) => void |
— | Every change, including the closes the browser started. |
unstyled |
boolean |
false |
Drops every class the component writes — the root’s pair, the menu’s three and each row part — and keeps the roles, the attributes and the whole keyboard. |
The item union
Section titled “The item union”DropdownItem is DropdownAction | DropdownCheckbox | DropdownSeparator | DropdownGroup,
discriminated on kind. The first two share DropdownRowBase:
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
— required | The row’s text. A string, because typeahead matches it and the row truncates it. |
icon |
ReactNode |
— | Leading glyph, in the shared gutter. Decorative; the slot is aria-hidden. |
shortcut |
ReactNode |
— | Trailing hint, muted on the far edge. Decorative as well. |
disabled |
boolean |
false |
aria-disabled rather than removed: still announced, skipped by the arrows and by typeahead. |
DropdownAction — kind: "item", a row that does something and closes.
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect |
() => void |
— required | Runs on click, on Enter and on Space. The menu closes after it. |
tone |
"danger" |
— | Paints the row and its highlight in the error role. |
submenu |
never |
— | Reserved name, not a prop. |
DropdownCheckbox — kind: "checkbox", a row that toggles and stays.
| Prop | Type | Default | Description |
|---|---|---|---|
checked |
boolean |
— required | Controlled: the consumer owns it. |
onCheckedChange |
(checked: boolean) => void |
— required | Fires with the new state. The menu stays open and the row keeps focus. |
DropdownGroup — kind: "group" plus a label: string.
DropdownSeparator — kind: "separator" and nothing else.
Accessibility
Section titled “Accessibility”- The trigger says what it opens.
aria-haspopup="menu",aria-expandedtracking the state, andaria-controlspointing at the surface. The menu names itself back witharia-labelledby, so the trigger’s own text is the menu’s accessible name — no second string to translate. - Rows are
menuitemormenuitemcheckbox, witharia-checkedon the checkable ones. A disabled row keepsaria-disabled="true"and stays in the tree, so a screen reader user hears that the command exists and is unavailable rather than never hearing about it. - Focus is real, not simulated. One row holds
tabindex="0"and the rest-1; the active row is focused for real, so the highlight is the focus indicator instead of a painted echo of one. A long menu scrolls, and the region it makes always contains that single tab stop. - Escape is consumed here, at the innermost open surface, and returns focus
to the trigger.
Tabalso closes and returns focus but lets the default action run, so focus lands on the next control instead of on the body. - Reduced motion turns the panel’s entrance into a 160ms fade in place and makes the row highlight instant, through variables, never properties.
- Windows high contrast gives the active row
Highlight/HighlightTextwithforced-color-adjust: none, a disabled rowGrayText, and repaints the separator fromCanvasText. The check mark keeps its row’s system colour throughforced-color-adjust: preserve-parent-color, so a disabled row’s check isGrayTexttoo.
Do and don’t
Section titled “Do and don’t”Do
Commands. Verbs the menu runs, one tab stop for the whole widget, arrow keys inside it.
Don't
Site navigation wearing menu semantics. Every link stops being its own tab stop, and a screen reader can no longer list the page's links.
Do
The unavailable command stays, greyed and aria-disabled. The menu keeps its shape and the row is still announced.
Don't
The row filtered out instead. The menu changes shape between visits, and nothing tells the user the command exists at all.