---
title: 'Menus and Dropdown Panels'
source: 'https://academia.sh/en/courses/accessible-patterns/menus-and-dropdown-panels'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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"`.

```html
<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.**

```js
// 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 `Escape`
  must 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.
