Accessible dropdown menu
A nav dropdown that opens on hover, click and keyboard, with arrow-key support.
<nav class="menubar" aria-label="Main">
<a class="item" href="/">Home</a>
<div class="hasmenu">
<button class="item" aria-expanded="false" aria-controls="m-products">
Products
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="panel" id="m-products">
<a href="/shirts/"><strong>Shirts</strong><span>Linen, oxford and flannel</span></a>
<a href="/knitwear/"><strong>Knitwear</strong><span>Lambswool and merino</span></a>
<a href="/accessories/"><strong>Accessories</strong><span>Scarves, belts and bags</span></a>
</div>
</div>
<div class="hasmenu">
<button class="item" aria-expanded="false" aria-controls="m-about">
About
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="panel" id="m-about">
<a href="/studio/"><strong>The studio</strong><span>Where things are made</span></a>
<a href="/people/"><strong>People</strong><span>Who makes them</span></a>
</div>
</div>
<a class="item" href="/contact/">Contact</a>
</nav>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 20px; }
.menubar {
display: flex;
gap: 2px;
padding: 6px;
border: 1px solid #e2e7ef;
border-radius: 10px;
background: #fff;
width: max-content;
}
.item {
font: inherit;
font-size: .94rem;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 14px;
border: 0;
border-radius: 7px;
background: none;
color: #5b6b83;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.item:hover { background: #f2f5f9; color: #16202e; }
.item:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 2px; }
.item svg {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
transition: transform .16s ease;
}
.item[aria-expanded="true"] { background: #eef0fe; color: #2a3abf; }
.item[aria-expanded="true"] svg { transform: rotate(180deg); }
.hasmenu { position: relative; }
.panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 260px;
padding: 6px;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(22,32,46,.04), 0 14px 34px -18px rgba(22,32,46,.4);
display: none;
z-index: 20;
}
/* hover for mouse, aria-expanded for keyboard and touch */
.hasmenu:hover > .panel,
.item[aria-expanded="true"] + .panel { display: block; }
.panel a {
display: block;
padding: 9px 12px;
border-radius: 6px;
text-decoration: none;
color: #16202e;
}
.panel a:hover { background: #f2f5f9; }
.panel a strong { display: block; font-weight: 500; font-size: .92rem; }
.panel a span { display: block; font-size: .81rem; color: #8593ab; margin-top: 1px; }
@media (hover: none) {
/* on touch, hover would open and never close */
.hasmenu:hover > .panel { display: none; }
.item[aria-expanded="true"] + .panel { display: block; }
}const buttons = [...document.querySelectorAll(".hasmenu > .item")];
function closeAll(except) {
buttons.forEach((b) => {
if (b !== except) b.setAttribute("aria-expanded", "false");
});
}
buttons.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = btn.getAttribute("aria-expanded") === "true";
closeAll(btn);
btn.setAttribute("aria-expanded", open ? "false" : "true");
});
});
document.addEventListener("click", () => closeAll(null));
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = buttons.find((b) => b.getAttribute("aria-expanded") === "true");
if (open) {
open.setAttribute("aria-expanded", "false");
open.focus();
}
});
// arrow keys walk the open panel
document.querySelector(".menubar").addEventListener("keydown", (e) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const group = e.target.closest(".hasmenu");
if (!group) return;
const btn = group.querySelector(".item");
const links = [...group.querySelectorAll(".panel a")];
if (!links.length) return;
if (e.target === btn) {
e.preventDefault();
btn.setAttribute("aria-expanded", "true");
links[e.key === "ArrowDown" ? 0 : links.length - 1].focus();
return;
}
const i = links.indexOf(e.target);
if (i === -1) return;
e.preventDefault();
const next = e.key === "ArrowDown" ? i + 1 : i - 1;
if (next < 0) btn.focus();
else if (next < links.length) links[next].focus();
});How it works
Dropdowns are easy to build badly. The three failure modes, and what fixes each:
Hover-only menus are unusable on touch. A tap fires a synthetic hover, the menu opens, and the next tap anywhere fires it again. The @media (hover: none) block turns hover off on touch devices and leaves the click handler to do the work.
A div with a click handler cannot be reached by keyboard. The trigger here is a <button>, so it is focusable, activates on Enter and Space, and announces itself.
Focus gets lost on close. Pressing Escape moves focus back to the button that opened the menu. Without that, focus falls to the top of the document and a keyboard user has to tab all the way back.
Arrow keys. Down from the button opens the panel and focuses the first link. Up focuses the last. Arrowing past the top returns to the button. This is the behaviour people expect from a menu and almost nobody implements.
Accessibility notes
aria-expanded on the trigger and aria-controls pointing at the panel id are the two attributes that make this legible to assistive technology.
This is a navigation menu, not an application menu, so it deliberately does not use role="menu". That role brings a different keyboard contract — arrow keys only, links removed from the tab order — which is right for a desktop-application menu bar and wrong for site navigation.
Making it yours
For a full-width mega menu, change .panel from position: absolute; left: 0 to left: 50%; transform: translateX(-50%) and widen the min-width. Columns inside it are a plain CSS grid.
Watch the gap between the button and the panel. A 6-pixel gap breaks the hover chain as the pointer crosses it. Either remove the gap and use padding inside the panel, or add a transparent bridge with a pseudo-element.
Related templates
Responsive navbar
A header that collapses to a hamburger under 820px. No framework, keyboard accessible.
HTMLCSSJavaScriptTailwindCollapsible sidebar
A dashboard sidebar that collapses to icons and becomes a drawer on mobile.
HTMLCSSJavaScriptBreadcrumbs
A breadcrumb trail with the correct markup and matching structured data.
HTMLCSSModal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
HTMLCSSJavaScriptCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.