Account Menu
A non-modal, button-invoked account menu that reveals a small list of links and optional actions. Uses aria-expanded plus DOM show/hide so keyboard users Tab into items and Esc closes.
Selection
The criteria an agent checks before retrieving this component.
Use when
- Use when providing a compact set of account-related destinations or actions (Profile, Settings, Billing, Sign out).
- Use when the trigger is a dedicated control (avatar button, "Account" button) with no other primary action.
- Use when the menu content is short and stable (typically 3–10 items).
- Use when (usually) the menu is in the top-right area of a webpage.
Try a different component when
- Do not use for primary site navigation with multiple sibling dropdowns (use
navigation-menu.basic). - Do not use for choosing a form value (use
select.basic, orcomboboxif searchable). - Do not use for dense command palettes or application-style decision trees that require arrow-key navigation and focus management (use
menu.menubar).
Must Haves
Non-negotiable structure. Every generated instance must satisfy these rules.
- The invoking control is a native
<button>(preferred), orrole="button"only when a native button cannot be used.- If using
role="button", addtabindex="0"and keyboard support for Enter and Space, ensuring Space prevents page scrolling while activating.
- If using
- The invoking button reflects open state of the menu with
aria-expanded="true|false". This state toggle is the core signal of the pattern; the implementation fails without it. - The invoking button is associated to the menu container with
aria-controls="IDREF". - The menu content is shown/hidden in the DOM (for example via the
hiddenattribute) so that when closed, menu content cannot be reached by keyboard or screen readers. - When the menu opens, focus remains on the invoking button.
- Keyboard users reach the first menu item with Tab.
- The menu does not trap focus. Users can Tab through the menu items and continue to the rest of the page.
- When focus moves outside the button + menu (Tab away, click elsewhere), the menu closes.
- Esc closes the menu and returns focus to the invoking button.
- Menu items are contained in a list structure (
<ul><li>…</li></ul>).- Items are links, buttons, and/or static text.
- Any buttons and links comply with the rules for the button/link patterns.
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the button that opens the menu, as well as the links and buttons inside.
Donts
Avoid these accessibility and UX barriers.
- Do not render the invoking button without
aria-expanded;aria-haspopupalone does not convey open/closed state. - Do not make hover or pointer click the only way to open the menu; opening must also work with keyboard Enter and Space (native button activation).
- Do not use
role="menu"/role="menuitem"unless you implement the full keyboard and focus behavior expected of composite menu widgets. - Do not position the menu in the DOM far away from the trigger in a way that breaks focus order or causes screen readers to encounter the menu in an unexpected location.
- Do not leave the menu visible or accessible to screen readers while
aria-expanded="false"(and vice versa). - Do not close the menu in a way that strands focus (for example removing the focused element without moving focus).
Customizable
Alternatives and options that give the AI agent some room to move.
- Whether the menu closes when a menu item is activated (often yes).
- Whether the menu includes non-interactive text (for example signed-in email) or separators.
- Menu positioning (left/right alignment, above/below) as long as it does not break reading and focus order.
- Accessible labeling strategy:
- Trigger button label (for example "Account" or "Open account menu").
- Optional label for the list (for example
aria-label="Account"on the list, or wrap with a labeled container).
Golden Pattern
The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.
"use client";
export function AccountMenu() {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const listRef = useRef(null);
const close = useCallback(() => setOpen(false), []);
const toggle = useCallback(() => setOpen(v => !v), []);
// Close on outside click (non-modal).
useEffect(() => {
if (!open) return;
const onPointerDown = (e) => {
const btn = btnRef.current;
const list = listRef.current;
if (!btn || !list) return;
if (!btn.contains(e.target) && !list.contains(e.target)) close();
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, close]);
// Close when focus leaves button + list.
useEffect(() => {
if (!open) return;
const onFocusIn = (e) => {
const btn = btnRef.current;
const list = listRef.current;
if (!btn || !list) return;
if (!btn.contains(e.target) && !list.contains(e.target)) close();
};
document.addEventListener("focusin", onFocusIn, true);
return () => document.removeEventListener("focusin", onFocusIn, true);
}, [open, close]);
// Esc closes and returns focus to the trigger.
useEffect(() => {
if (!open) return;
const onKeyDown = (e) => {
if (e.key !== "Escape") return;
e.preventDefault();
close();
requestAnimationFrame(() => btnRef.current?.focus());
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [open, close]);
// Close on Tab out (last->next) and Shift+Tab out (first->button).
const onListKeyDown = (e) => {
if (!open || e.key !== "Tab") return;
const list = listRef.current;
if (!list) return;
const focusables = Array.from(
list.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!e.shiftKey && document.activeElement === last) close();
if (e.shiftKey && document.activeElement === first) close();
};
return (
<div>
<button
ref={btnRef}
id="account-menu-button"
type="button"
aria-haspopup="true"
aria-expanded={open ? "true" : "false"}
aria-controls="account-menu"
onClick={toggle}
>
Account
</button>
<ul
id="account-menu"
ref={listRef}
hidden={!open}
aria-label="Account"
onKeyDown={onListKeyDown}
>
<li><a href="/profile">Profile</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/billing">Billing</a></li>
<li><button type="button" onClick={close}>Sign out</button></li>
</ul>
</div>
);
}
Acceptance Checks
The component’s test spec — an optional body of checks for verification.
Keyboard
- Tab lands on the invoking button.
- Enter or Space activates the button to open and close the menu.
- When opened, focus remains on the invoking button.
- Tab moves from the button into the first menu item, then through each item, then out to the next focusable element on the page.
- Shift+Tab moves backward from the first menu item to the invoking button.
- Esc closes the menu and returns focus to the invoking button.
- If focus moves outside the button + menu (Tab away or click elsewhere), the menu closes.
Screen Reader
- The invoking button announces expanded/collapsed state via
aria-expanded; the attribute is present and flips between"true"and"false"on every open and close. - When closed, menu items are not reachable.
- When open, the list and its items are reachable in reading and focus order.
- Links announce as links and navigate correctly.
- Buttons announce as buttons and activate correctly.