Accessible Dropdown Menu with ARIA Roles
This snippet creates an accessible dropdown menu using ARIA roles and modern JavaScript for better usability.
Code
<!--
Accessible Dropdown Menu (ARIA + keyboard support).
Use for navigation menus requiring robust accessibility and UX.
-->
<nav aria-label="Primary">
<ul class="dropdown">
<li class="dropdown-item">
<button
class="dropdown-toggle"
type="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="dropdown-menu-1"
id="dropdown-button-1"
>
Menu
</button>
<ul
class="dropdown-menu"
id="dropdown-menu-1"
role="menu"
aria-labelledby="dropdown-button-1"
>
<li role="none"><a role="menuitem" href="#item-1" tabindex="-1">Item 1</a></li>
<li role="none"><a role="menuitem" href="#item-2" tabindex="-1">Item 2</a></li>
<li role="none"><a role="menuitem" href="#item-3" tabindex="-1">Item 3</a></li>
</ul>
</li>
</ul>
</nav>
<style>
.dropdown { list-style: none; margin: 0; padding: 0; }
.dropdown-item { position: relative; display: inline-block; }
.dropdown-toggle {
appearance: none;
border: 1px solid #d0d7de;
background: #fff;
padding: .5rem .75rem;
border-radius: .5rem;
cursor: pointer;
}
.dropdown-toggle:focus-visible { outline: 3px solid #2f81f7; outline-offset: 2px; }
.dropdown-menu {
position: absolute;
top: calc(100% + .5rem);
left: 0;
min-width: 12rem;
list-style: none;
margin: 0;
padding: .25rem;
border: 1px solid #d0d7de;
border-radius: .75rem;
background: #fff;
box-shadow: 0 10px 30px rgba(0,0,0,.12);
display: none;
}
.dropdown-menu[hidden] { display: none; }
.dropdown-menu a {
display: block;
padding: .5rem .6rem;
border-radius: .5rem;
text-decoration: none;
color: #111;
}
.dropdown-menu a:focus, .dropdown-menu a:hover { background: #f3f4f6; }
</style>
<script>
// Initializes a single dropdown with click, Escape, outside-click, and roving tabindex.
(() => {
const root = document.querySelector('.dropdown-item');
if (!root) return;
const button = root.querySelector('.dropdown-toggle');
const menu = root.querySelector('.dropdown-menu');
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
if (!button || !menu || items.length === 0) return;
const setOpen = (open) => {
button.setAttribute('aria-expanded', String(open));
menu.hidden = !open; // Uses [hidden] to avoid layout thrash from inline styles
items.forEach((el) => el.tabIndex = -1);
if (open) items[0].tabIndex = 0;
};
const isOpen = () => button.getAttribute('aria-expanded') === 'true';
const focusItem = (index) => {
const next = items[(index + items.length) % items.length];
items.forEach((el) => el.tabIndex = -1);
next.tabIndex = 0;
next.focus();
};
// Initial state
menu.hidden = true;
button.addEventListener('click', () => {
const open = !isOpen();
setOpen(open);
if (open) items[0].focus();
});
button.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isOpen()) setOpen(true);
items[0].focus();
}
});
menu.addEventListener('keydown', (e) => {
const currentIndex = items.indexOf(document.activeElement);
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); button.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(currentIndex + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(currentIndex - 1); }
if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
if (e.key === 'End') { e.preventDefault(); focusItem(items.length - 1); }
if (e.key === 'Tab') setOpen(false); // allow tabbing away while closing menu
});
// Close on outside click (capture phase reduces race with focus changes).
document.addEventListener('pointerdown', (e) => {
if (!isOpen()) return;
if (!root.contains(e.target)) setOpen(false);
}, { capture: true });
// Close when focus leaves the dropdown entirely.
root.addEventListener('focusout', () => {
requestAnimationFrame(() => { // waits for focus to settle
if (isOpen() && !root.contains(document.activeElement)) setOpen(false);
});
});
// Example usage: duplicate .dropdown-item blocks for multiple menus and run init per root.
})();
</script>