Responsive navbar
A header that collapses to a hamburger under 820px. No framework, keyboard accessible.
<header class="site-head">
<a class="brand" href="/">Acme</a>
<button class="burger" id="burger" aria-expanded="false" aria-controls="primary-nav" aria-label="Open menu">
<span></span><span></span><span></span>
</button>
<nav class="primary" id="primary-nav" aria-label="Main">
<a href="/work/" aria-current="page">Work</a>
<a href="/studio/">Studio</a>
<a href="/journal/">Journal</a>
<a href="/contact/">Contact</a>
<a class="cta" href="/enquire/">Start a project</a>
</nav>
</header>
<main style="padding:40px 24px;font:16px/1.6 system-ui,sans-serif;color:#5b6b83">
<p>Resize the preview to see the menu collapse.</p>
</main>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
.site-head {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: rgba(255, 255, 255, .9);
backdrop-filter: blur(10px);
border-bottom: 1px solid #e2e7ef;
}
.brand { font-weight: 700; font-size: 1.1rem; color: #16202e; text-decoration: none; }
.primary { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.primary a {
color: #5b6b83;
text-decoration: none;
font-size: .94rem;
padding: 8px 12px;
border-radius: 6px;
}
.primary a:hover { color: #16202e; background: #f2f5f9; }
.primary a[aria-current="page"] { color: #3b4fe4; background: #eef0fe; }
.primary .cta {
background: #3b4fe4;
color: #fff;
margin-left: 8px;
padding: 9px 16px;
}
.primary .cta:hover { background: #2a3abf; color: #fff; }
/* ---- the hamburger ---- */
.burger {
display: none;
margin-left: auto;
width: 40px;
height: 36px;
padding: 9px 8px;
border: 1px solid #e2e7ef;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.burger span {
display: block;
height: 2px;
background: #16202e;
border-radius: 2px;
transition: transform .22s ease, opacity .18s ease;
}
.burger span + span { margin-top: 4px; }
/* three bars become an X */
.burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* ---- mobile ---- */
@media (max-width: 820px) {
.burger { display: block; }
.primary {
position: fixed;
inset: 65px 0 auto 0;
flex-direction: column;
align-items: stretch;
gap: 0;
margin: 0;
padding: 8px 20px 20px;
background: #fff;
border-bottom: 1px solid #e2e7ef;
box-shadow: 0 12px 28px -20px rgba(22, 32, 46, .5);
display: none;
}
.primary.open { display: flex; }
.primary a { padding: 13px 8px; border-radius: 0; border-bottom: 1px solid #f2f5f9; font-size: 1rem; }
.primary .cta { margin: 12px 0 0; border-radius: 8px; text-align: center; border: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burger span { transition: none; }
}const burger = document.getElementById("burger");
const nav = document.getElementById("primary-nav");
function setOpen(open) {
nav.classList.toggle("open", open);
burger.setAttribute("aria-expanded", open ? "true" : "false");
burger.setAttribute("aria-label", open ? "Close menu" : "Open menu");
}
burger.addEventListener("click", () => {
setOpen(burger.getAttribute("aria-expanded") !== "true");
});
// Escape closes the menu and returns focus to the button
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && nav.classList.contains("open")) {
setOpen(false);
burger.focus();
}
});
// clicking outside closes it
document.addEventListener("click", (e) => {
if (!nav.contains(e.target) && !burger.contains(e.target)) setOpen(false);
});
// reset when resizing back to desktop, so the menu is not stuck hidden
matchMedia("(min-width: 821px)").addEventListener("change", (e) => {
if (e.matches) setOpen(false);
});<header class="sticky top-0 z-50 flex items-center gap-4 border-b border-slate-200 bg-white/90 px-6 py-3.5 backdrop-blur">
<a class="text-lg font-bold text-slate-900" href="/">Acme</a>
<button id="burger" aria-expanded="false" aria-controls="primary-nav" aria-label="Open menu"
class="ml-auto rounded-md border border-slate-200 p-2 md:hidden">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<nav id="primary-nav" aria-label="Main"
class="fixed inset-x-0 top-16 hidden flex-col border-b border-slate-200 bg-white p-5 shadow-lg
md:static md:ml-auto md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:p-0 md:shadow-none">
<a class="rounded-md px-3 py-2 text-sm text-indigo-600 md:bg-indigo-50" href="/work/" aria-current="page">Work</a>
<a class="rounded-md px-3 py-2 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-900" href="/studio/">Studio</a>
<a class="rounded-md px-3 py-2 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-900" href="/journal/">Journal</a>
<a class="rounded-md px-3 py-2 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-900" href="/contact/">Contact</a>
<a class="mt-3 rounded-lg bg-indigo-600 px-4 py-2 text-center text-sm text-white hover:bg-indigo-700 md:ml-2 md:mt-0"
href="/enquire/">Start a project</a>
</nav>
</header>
<script>
const burger = document.getElementById("burger");
const nav = document.getElementById("primary-nav");
burger.addEventListener("click", () => {
const open = burger.getAttribute("aria-expanded") !== "true";
nav.classList.toggle("hidden", !open);
nav.classList.toggle("flex", open);
burger.setAttribute("aria-expanded", String(open));
});
</script>How it works
The hamburger is a real <button> and the menu is a real <nav>. That sounds obvious and it is the thing most implementations get wrong — a <div> with a click handler is invisible to keyboards and announces nothing to a screen reader.
aria-expanded does double duty. It tells assistive technology whether the menu is open, and it is also the CSS hook that animates the bars into an X. One attribute, both jobs, no extra class to keep in sync.
aria-controls points at the menu's id so the relationship is explicit rather than positional.
The resize listener matters more than it looks. Open the menu on a phone, rotate to landscape, and without that handler the menu can end up hidden while the button still says it is open. Watching the media query keeps the two in step.
Sticky, not fixed. position: sticky keeps the header in flow, so it does not overlap the first paragraph the way a fixed header does unless you remember to add padding.
Accessibility notes
Keyboard users can tab to the button, press Enter or Space to open, tab through the links and press Escape to close — which also returns focus to the button rather than dumping it at the top of the document.
aria-current="page" marks the current page for screen readers. It is separate from the visual highlight and both should be present.
Motion is disabled under prefers-reduced-motion. The bars still become an X, they just do it instantly.
Making it yours
The breakpoint is in one place — change 820px in both the media query and the resize listener together, or they drift apart.
For a dropdown inside this bar, the dropdown menu template uses the same aria pattern and slots straight in.
Related templates
Accessible dropdown menu
A nav dropdown that opens on hover, click and keyboard, with arrow-key support.
HTMLCSSJavaScriptCollapsible 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.
HTMLCSSResponsive grid without media queries
Four grid recipes that reflow on their own, using auto-fit, minmax and container queries.
HTMLCSSCheck 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.