// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
})();
// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
})();
About this set
Three account menus for the top right corner of an app. Avatar menu with a profile header sits in a small app bar: the avatar has an online dot, and the menu opens with the person’s name and email above Your profile, Settings, Billing with a Pro badge, Keyboard shortcuts, Help centre and Sign out. Workspace switcher is a full-width button showing the current workspace, plan and member count; its menu lists workspaces with a tick on the current one, then Create workspace and Join with an invite code. Profile card with plan usage is a pill with avatar and name whose menu shows the plan, a storage meter that doubles as a link, an Upgrade plan item and the team size. The profile header is the visible label of the first group, so a screen reader announces the name and email on entering the menu. The current workspace is marked with aria-current rather than colour alone, and the meter bar is decorative because the item text already says 7.2 of 10 GB. Menus align to the end of their trigger where they would otherwise run off the page.
What’s included
Avatar trigger with a status dot and an accessible name
Profile header that labels the first menu group
Workspace switcher with aria-current on the active workspace
Plan badge, count badge and a storage meter item
End-aligned menu for triggers at the right edge
Accessibility
The avatar-only trigger is named "Account menu for Maya Okafor", and every trigger exposes aria-haspopup, aria-expanded and aria-controls. The profile header labels the first role="group", the current workspace carries aria-current, and decorative avatars and the meter bar are hidden from assistive technology. Items and triggers have visible focus outlines, keyboard handling follows the WAI-ARIA menu button pattern, and animations stop under prefers-reduced-motion.
Customise it
Change the --ac-* custom properties on .ac-wrap for ink, lines, hover and the teal accent, and the gradients on .ac-av and .ac-logo for avatars. Bootstrap uses the same properties plus --bs-dropdown-* on .ac-menu; in Tailwind edit the hex values in the classes.