Chapter 11
The anatomy of a menu
A menu offers a choice, but every choice steals a little context: it covers what you were looking at, moves the focus, asks you to remember where you were. The craft isn't opening a list — it's offering the roads while keeping the user anchored to where they stand.
The last chapter was about the single control; this one widens the view to a gesture that sounds trivial and is in fact among the most delicate in the interface: offering a choice. A menu is exactly that — a list of roads to take one of. But every list that opens pays a hidden price: it covers something, moves the focus, asks the user to remember where they were before opening it. The chapter’s question is all here: how do you offer a choice without hiding the context?
There’s no single answer, because menus aren’t all the same object. There’s the dropdown that grows attached to its button, so the eye never loses the thread. There’s the right-click menu, born where you click and which must never escape the screen. There’s the listbox, the choice that stays visible while you navigate it. There’s the split button, which gives the most likely action at once and hides the variants behind a little arrow. And there’s the command palette, which turns it all over: instead of navigating hierarchies, you type what you want. Five ways of answering the same question, each with a different trade-off between speed and context.
Five demos, and as always no libraries. The web of 2026 finally has the native tools: the Popover API that opens and closes in the top layer, CSS Anchor Positioning that anchors a panel to its trigger with no JS, the <dialog> that traps focus on its own. Our job is to stitch them together with the right ARIA — role="menu", role="listbox", aria-expanded, aria-activedescendant — because an inaccessible menu is a menu that, for half the people, simply doesn’t open.
The menu that stays attached to the button
The first is the dropdown in its most honest form: it grows from under the trigger that opened it, doesn’t fly to the centre and doesn’t cover the page. It’s the form that least disturbs the context, because the eye stays exactly where it was.
Opens under the button; Esc closes.
Anchored dropdown · Popover API + CSS Anchor Positioning
Under the skin there are two native APIs, finally mature, that do almost everything on their own. The Popover API (popover + popovertarget) hands you opening, closing with Esc, light-dismiss on outside click, and — for free — rising into the top layer, above any overflow:hidden: no z-index wars, no hand-rolled portals. CSS Anchor Positioning (anchor-name/position-anchor) anchors the panel to the button in pure CSS, without a single getBoundingClientRect in a scroll loop. It’s the “native beats libraries” chapter taken to its peak.
Honesty lives in the dignified fallback: where anchor positioning isn’t there yet, an @supports leaves the panel absolutely positioned under the trigger — same result, one line of magic less. Accessibility isn’t an afterthought: the trigger carries aria-haspopup and aria-expanded, the panel is a role=“menu” with ↑/↓ and Home/End arrows, and on close the focus returns to the button. With prefers-reduced-motion the entrance animation collapses to 1ms: the menu is simply already there, no slide.
The menu born where you click
Second way, opposite to the first: instead of a fixed point, the menu appears at the pointer. It’s the right-click menu — very handy because it’s born where your hand already is, but with a concrete danger: the pointer can be flush with the edge, and a menu that runs off the screen is an unusable menu.
Context menu · always inside the viewport
The rule is sharp: the menu never leaves the window. It starts at the click point, but if it doesn’t fit down-and-right it flips left and/or upward, back toward the pointer. We do it by measuring the real panel after rendering it and then clamping to the viewport edges: it’s the calculation every “floating” library hides, but in a context menu you need it by hand, because the origin point is decided by the user, not by us. We intercept the contextmenu event and prevent the browser’s native menu only inside the marked surface.
Right-click is a mouse gesture, and here’s the accessibility trap: keyboard users press the menu key or Shift+F10, which still emit a contextmenu — so we open it centred on the surface, not at coordinate zero. Inside, ↑/↓ move, Esc closes and focus returns to the surface. The “Delete” item is dangerous, but red isn’t the only signal: the text stays legible and hover marks it with a background, not just a tint — because someone who can’t tell red apart must be able to recognise it all the same.
The choice that stays in sight
Third way: sometimes the choice shouldn’t be hidden at all. The listbox keeps the list always visible and you navigate inside it — it’s the right control when you want the user to compare the options, not discover them one at a time.
- Amsterdam
- Berlin
- Copenhagen
- Dublin
- Lisbon
- London
- Madrid
- Oslo
- Prague
- Vienna
Arrows to move, type to search, Enter to choose.
Accessible listbox · aria-activedescendant + typeahead
Native HTML has no generic, stylable <listbox>, so this is the most “pure ARIA” pattern of the chapter, built by the book according to the APG: role=“listbox” on the container, role=“option” on every item, aria-selected on the choice. The crux is aria-activedescendant: the real DOM focus stays on the container, and the attribute points to the active option. The screen reader announces it without the focus actually moving — it’s the technique that lets you have “the highlighted option” distinct from “the focused element”, impossible with DOM focus alone.
The keyboard is the real, complete one: ↑/↓ move the active option, Home/End to the ends, Enter/Space select. And typeahead — you type “lo” and it jumps to “London” — because in long lists that’s the gesture people actually use, not arrow-scrolling. Selection isn’t only colour: there’s the check that draws itself in and the text weight that grows, a double channel. With prefers-reduced-motion the scroll toward the active option goes from smooth to instant: same result, no scrolling.
The action that carries its cousins
Fourth way, the subtlest: sometimes you don’t need a menu at all. The split button gives the most likely action at once and hides the variants behind a little arrow — those who know what they want open nothing, those looking for an alternative find it one click away.
Split button · primary action + variants menu
They’re two real buttons, side by side, that look like a single object: the primary action on the left, the arrow that opens the variants on the right. The UX value is the dosage of context — the user has to choose nothing to do the most common thing (“Save”), and the alternatives stay within reach without cluttering. It’s the opposite of the menu that forces you to decide before you even know what you want: here the default is the decision, until you ask for more.
The ARIA is where almost everyone slips. The “menu open” state belongs only to the arrow: it’s the one that carries aria-haspopup=“menu” and aria-expanded. Putting aria-expanded on the whole group, or worse on the primary action — which opens nothing — is the classic mistake that confuses the screen reader. From the arrow, ↓ opens and goes to the first item; in the menu ↑/↓ move, Esc closes and focus returns to the arrow. The entrance is ~220ms, the exit quicker; with reduced motion the animation collapses to 1ms.
The choice that starts from what you type
Fifth way, and it overturns all the others: instead of navigating hierarchies, you type what you want and the action emerges. The ⌘K-style command palette is the most radical answer to the chapter’s question, because it hides no context: it starts from the user’s typed intent.
Command palette · ⌘K inside a <dialog>
The container is a native <dialog>, and here’s the chapter’s manifesto win: showModal() hands you the focus trap for free — Tab stays trapped inside without a single handler to cycle the focusable elements — plus Esc that closes (the cancel event) and the ::backdrop that dims everything else. Above it, a textbox with role=“combobox” wired to a role=“listbox”: you type, the list filters in real time, the arrows move the active option via aria-activedescendant, Enter runs it. ⌘K/Ctrl+K open it from anywhere.
Honest in the void too: if no command matches, we say so — “No command found” — instead of showing a fake list or the full list as if nothing happened. It’s the same rule as the empty-states chapters: nothing must be named, not masked. The palette is immensely powerful for those who live on the keyboard, but it stays an accelerator, not the only path: every command that lives here must also exist in a visible menu, because not everyone knows ⌘K exists. With prefers-reduced-motion the panel’s entrance collapses to 1ms — it appears resolved, no journey.
Five ways, one single tension: every menu is a trade-off between giving you the roads and keeping you anchored to where you are. The anchored dropdown chooses not to move the eye; the context menu to be born under the hand yet stay on screen; the listbox not to hide at all; the split button to decide for you until you ask for more; the palette to start from what you type. None is “the” right menu: the right one is the one whose trade-off matches how much context the user can afford to lose at that moment. Offering a choice, in the end, is mostly deciding how much of the rest you’re willing to cover to show it.
The next chapter stays among components but shifts register again: from the menu that offers roads to the tab that swaps one out from under you. The anatomy of a tab — how to change context without disorienting, when it’s the panel itself, not an overlay, that transforms.