Lesson 10 / 25
Menus and Dropdown Panels
The distinction between the menu role and the dropdown panel, scanning three setups for focus leaks and closing behavior, and verifying the focus return point and the open-close cycle.
Contents
In the tab and accordion patterns, opened content stayed inside the page’s flow: it took up a place, pushed the content beneath it down, and focus moved on its own. The “My Account” control in the catalog’s top bar behaves differently — the list it opens sits above the page, covers the content beneath it, and closes when the user clicks outside it.
This is the first pattern where closing has to be specified as a behavior. Closing happens in more than one way, and where focus goes after each way is a separate decision. The lesson takes on both patterns together, because although they look alike on screen, their contracts are separate.
Two Patterns, Two Contracts
A dropdown panel is ordinary content that a button shows and hides. It can hold a
link, a button, a field, even a whole form. It uses the same declaration as the
accordion — aria-expanded — and its only difference is that the content sits above the
page.
A menu is an application-like list of commands, and it is built with its own role
set: role="menu", role="menuitem", role="menuitemcheckbox". These roles carry a
contract, and the contract is restrictive: a menu holds only menu items, navigation is
done with the arrow keys, and the whole menu is a single tab stop.
The deciding measure is the content. The list the “My Account” control opens — “My Loans,” “Settings,” “Sign Out” — is made of ordinary links and is a navigation list; its pattern is a dropdown panel. The menu role is used for lists whose items are actions rather than navigation, like the command list the “Export” button opens on the results table.
The wrong choice produces a silent cost. When the menu role is written on a list of links, the links are reported to the tree as menu items; reaching them from a link list becomes impossible, and the “open in new tab” expectation is not met.
The Native Element First
Neither has a native counterpart. The opening control is a button element; the
dropdown panel writes aria-expanded and aria-controls, and the menu additionally
declares aria-haspopup="menu".
<button id="account" aria-expanded="false" aria-controls="account-panel">My Account</button> <div id="account-panel" hidden> <ul> <li><a href="/account/loans">My Loans</a></li> <li><a href="/account/settings">Settings</a></li> </ul> <form action="/sign-out" method="post"><button type="submit">Sign Out</button></form> </div>
The panel’s content is ordinary markup; a list stays a list, a form stays a form. This is the reason the pattern is preferred over a menu: no structural meaning has to be reproduced by hand.
Closing Paths and the Focus Return
Closing happens four ways: running the opening button again, the Escape key, clicking
outside the panel, and selecting an item inside the panel.
Where focus goes depends on the closing path, and the rule is this: on a closing the
user initiated, focus returns to the opening button. This holds for closing with
Escape and by running the button again. When a link inside the panel is selected, that
is navigation and focus goes to the new page; a return is unnecessary.
Clicking outside is a special case. A user who clicks has already moved focus to where they clicked; returning focus to the opening button pulls it back from where they clicked. The rule: on an outside click, focus moves to the opening button only if it has settled nowhere else.
// menu.mjs — focus containment, closing, and the return point in a dropdown panel const BACKGROUND = ["a#catalog", "button#account", "input#search"]; const CONTENT = ["a#my-loans", "a#settings", "button#sign-out"]; const TRIGGER = "button#account"; const KEYS = ["Tab", "Shift+Tab", "ArrowDown", "ArrowUp", "Escape", "Enter", "ClickOutside"]; // kind: "panel" (dropdown panel) | "menu" (menu role) | "broken" function machine(kind) { const goBackground = (i, dir) => BACKGROUND[(i + dir + BACKGROUND.length) % BACKGROUND.length]; return (d, t) => { if (!d.open) { const i = BACKGROUND.indexOf(d.focus); if (t === "Tab") return { open: false, focus: goBackground(i, 1) }; if (t === "Shift+Tab") return { open: false, focus: goBackground(i, -1) }; if ((t === "Enter" || t === "ArrowDown") && d.focus === TRIGGER) return { open: true, focus: CONTENT[0] }; return d; } const j = CONTENT.indexOf(d.focus); if (kind === "menu") { if (t === "ArrowDown") return { open: true, focus: CONTENT[(j + 1) % CONTENT.length] }; if (t === "ArrowUp") return { open: true, focus: CONTENT[(j + CONTENT.length - 1) % CONTENT.length] }; if (t === "Tab") return { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) }; if (t === "Shift+Tab") return { open: false, focus: TRIGGER }; if (t === "Escape") return { open: false, focus: TRIGGER }; if (t === "ClickOutside") return { open: false, focus: TRIGGER }; return d; } if (kind === "panel") { if (t === "Tab") return j === CONTENT.length - 1 ? { open: false, focus: goBackground(BACKGROUND.indexOf(TRIGGER), 1) } : { open: true, focus: CONTENT[j + 1] }; if (t === "Shift+Tab") return j === 0 ? { open: true, focus: TRIGGER } : { open: true, focus: CONTENT[j - 1] }; if (t === "Escape") return { open: false, focus: TRIGGER }; if (t === "ClickOutside") return { open: false, focus: TRIGGER }; return d; } // broken: no escape, focus vanishes on outside click, Tab leaks to the background if (t === "Tab") return j === CONTENT.length - 1 ? { open: true, focus: BACKGROUND[0] } : { open: true, focus: CONTENT[j + 1] }; if (t === "Shift+Tab") return { open: true, focus: CONTENT[Math.max(j - 1, 0)] }; if (t === "ClickOutside") return { open: false, focus: "(document root)" }; return d; }; } const key = (d) => (d.open ? "open" : "closed") + "|" + d.focus; function audit(kind) { const next = machine(kind); const start = { open: false, focus: TRIGGER }; const seen = new Map([[key(start), start]]); const queue = [start]; const closings = []; // [previousState, key, nextState] while (queue.length) { const d = queue.shift(); for (const t of KEYS) { const y = next(d, t); if (d.open && !y.open) closings.push([d, t, y]); if (seen.has(key(y))) continue; seen.set(key(y), y); queue.push(y); } } const states = [...seen.values()]; const viaEscape = closings.filter(([, t]) => t === "Escape"); return { states: states.length, leak: states.some((d) => d.open && BACKGROUND.includes(d.focus) && d.focus !== TRIGGER), escapeExit: viaEscape.length > 0, escapeReturn: viaEscape.length > 0 && viaEscape.every(([, , y]) => y.focus === TRIGGER), focusLoss: closings.some(([, , y]) => !BACKGROUND.includes(y.focus) && y.focus !== TRIGGER), cycle: key(next(next(start, "Enter"), "Escape")) === key(start), innerStop: kind === "menu" ? 1 : CONTENT.length, }; } const yn = (b) => (b ? "yes" : "no"); console.log("setup".padEnd(10) + "states".padEnd(9) + "inner stop".padEnd(12) + "leaks to background".padEnd(21) + "Escape exit".padEnd(13) + "returns to trigger".padEnd(20) + "focus loss".padEnd(12) + "open-close cycle"); for (const kind of ["panel", "menu", "broken"]) { const s = audit(kind); console.log(kind.padEnd(10) + String(s.states).padStart(6).padEnd(9) + String(s.innerStop).padStart(5).padEnd(12) + yn(s.leak).padEnd(21) + yn(s.escapeExit).padEnd(13) + yn(s.escapeReturn).padEnd(20) + yn(s.focusLoss).padEnd(12) + (s.cycle ? "closed" : "OPEN")); } // Focus trace for the correct setup console.log("\nfocus trace in the dropdown panel setup:"); let d = { open: false, focus: TRIGGER }; const next = machine("panel"); console.log(" " + "start".padEnd(12) + "closed".padEnd(8) + "focus: " + d.focus); for (const t of ["Enter", "Tab", "Tab", "Escape", "Tab"]) { d = next(d, t); console.log(" " + t.padEnd(12) + (d.open ? "open" : "closed").padEnd(8) + "focus: " + d.focus); }
setup states inner stop leaks to background Escape exit returns to trigger focus loss open-close cycle panel 8 3 no yes yes no closed menu 6 1 no yes yes no closed broken 8 3 yes no no yes OPEN focus trace in the dropdown panel setup: start closed focus: button#account Enter open focus: a#my-loans Tab open focus: a#settings Tab open focus: button#sign-out Escape closed focus: button#account Tab closed focus: input#search
Reading the Findings
The inner stop column separates the two patterns. In the dropdown panel, the content sits in the natural tab order and holds three stops; the menu uses the roving tabindex and the whole thing counts as one stop. This is the measure that names which pattern is built without looking at the markup.
The open-close cycle has to be closed. The last column tests whether a user focused
on the button, who opens the panel and closes it with Escape, returns to exactly the
same state. In both correct setups the cycle is closed; in the broken setup it is open,
because the Escape key does nothing. A closed cycle means the user loses nothing by
opening the panel by accident.
The broken setup carries three defects at once. While the panel is open, the Tab
key leaks into the background controls: the user ends up focused on elements they cannot
see, hidden beneath the panel. There is no exit with Escape — this is the subject of
criterion 2.1.2, though it is not a full trap here, because Tab provides an escape.
Clicking outside drops focus to the document root, and the user starts navigating from
the very beginning.
The menu reaches six states, the panel eight. The difference comes from the panel’s
ability to return to the opening button while open: Shift+Tab from the first item exits
to the button and the panel stays open. In the menu, the same key closes the menu. Both
are defined; which one is chosen is written into the specification.
Keyboard Contract
Dropdown panel:
| Key | Behavior |
|---|---|
Enter / Space (on the button) |
Opens the panel; focus moves to the first item. |
Down arrow (on the button) |
Opens the panel and moves focus to the first item. |
Tab |
Advances through the panel’s items; closes the panel after the last item. |
Escape |
Closes the panel; focus returns to the opening button. |
Menu:
| Key | Behavior |
|---|---|
Enter / Space / Down arrow (on the button) |
Opens the menu; focus moves to the first item. |
Down arrow / Up arrow |
Moves between items; wraps at the ends. |
Home / End |
Goes to the first and last item. |
Escape |
Closes the menu; focus returns to the opening button. |
Tab |
Closes the menu and moves focus to the next page stop. |
In the menu pattern, the Tab key closing the menu is not a limitation but part of the
definition: because the menu is a single stop, there is no tab order inside it to advance
through.
Measurable Constraints
2.1.2 No Keyboard Trap. While the panel is open, it must be possible to exit with the
keyboard. Escape is the shortest path out and is required in both patterns.
1.4.13 Content on Hover or Focus. If the panel opens on pointer hover, three
conditions are required: it must be dismissible with Escape, the pointer must be able
to move over the panel, and it must not disappear without the user taking an action.
Menus that open on hover and close when the pointer moves away remove this criterion; a
pattern also has to open with a click, because a hover state never occurs on touch
input.
2.4.7 Focus Visible. The focus indicator on items inside the panel must also carry sufficient contrast against the panel’s own surface; a single-color ring tuned for the page surface can disappear on the panel surface.
1.4.11 Non-text Contrast. The panel’s border must meet 3:1 against the content beneath it so it does not blend in; a panel separated only by a shadow has no visible border.
2.5.8 Target Size. Panel items meet the 24-pixel measure; in narrow dropdown lists, the row height determines this measure.
Common Mistake
Writing the menu role onto a link list. Catching it is a structural check: if an
element carrying role="menu" has a child that is not a menu item, the pattern is
built wrong.
Leaving focus behind on close. If the panel closes while focus sits on an element that has been removed, focus drops to the document root. Catching it is the audit’s cycle test: if the open-close sequence does not return to the starting state, focus management is missing.
Tying Escape only to the panel. If Escape stops working once the user has moved
focus back to the opening button, one of the closing paths is lost. The key is bound to
the shared ancestor of the button and the panel.
Not hiding the panel from the tree. A panel hidden only in the presentation layer stays in the tree, and the user reads the items of a list they believe is closed.
Summary
- A dropdown panel shows and hides ordinary content; a menu is for lists whose items are commands and carries a restrictive role contract.
- Writing the menu role on a list of links turns the links into menu items in the tree and blocks reaching them from a link list.
- The dropdown panel’s content sits in the natural tab order; the menu uses the roving tabindex and holds a single stop. This measure tells the two patterns apart.
- On every closing the user initiates, focus returns to the opening button; closing by navigating from inside the panel needs no return.
- The open-close cycle must be closed: an open-close sequence starting at the button must return to exactly the same state.
- While the panel is open, focus must not leak to the background controls, and
Escapemust leave an exit in every case.
Next Step
The containers covered so far showed and hid content; none of them told the user where they are. In the catalog, position is information: the user is three levels down a subject branch and on the fourth page of the results list. Two components carry this information, and both rest on the same declaration — separating the current location from the rest. The next lesson specifies the breadcrumb and pagination: how position is declared, and why a page change requires a focus decision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.