A navbar is the one component that appears on every page of a site, which makes it the worst possible place to cut corners on semantics or accessibility. This walkthrough builds a complete, responsive navbar from real markup — a flexbox desktop layout, a hamburger menu that works without JavaScript, sticky positioning that doesn't jump on scroll, and the ARIA attributes that make it usable with a keyboard or screen reader, not just a mouse.
A navbar built from <div>s is invisible to assistive technology as a navigation landmark. Use <nav> with an aria-label, and structure links as an actual list — screen readers announce list length ("navigation, list of 5 items"), which a row of bare links doesn't provide.
<nav class="nav" aria-label="Main">
<a href="/" class="nav-logo">Brand</a>
<ul class="nav-links">
<li><a href="/product">Product</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/docs">Docs</a></li>
</ul>
<a href="/signup" class="nav-cta">Sign up</a>
</nav>
A navbar is a single row of items that needs even spacing on either end — the textbook case for Flexbox, not Grid, since there's only one axis to manage:
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
}
.nav-links {
display: flex;
gap: 28px;
list-style: none;
}
.nav-links a {
color: inherit;
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
The checkbox hack uses a hidden checkbox and the :checked pseudo-class to toggle a menu's visibility purely in CSS — genuinely useful for a static site or template where pulling in JS for one interaction isn't worth it.
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden>
<label for="nav-toggle" class="nav-burger" aria-label="Open menu">☰</label>
<ul class="nav-links">...</ul>
<style>
@media (max-width: 720px) {
.nav-links {
position: absolute;
top: 100%; left: 0; right: 0;
flex-direction: column;
max-height: 0;
overflow: hidden;
transition: max-height 250ms ease;
}
.nav-toggle:checked ~ .nav-links {
max-height: 320px;
}
}
</style>
The trade-off: because it's driven by a checkbox rather than a real button, it doesn't get aria-expanded for free, and closing the menu on link click needs an extra CSS-only trick or a tiny bit of JS. For a production app with more complex menu behavior, a JS-driven toggle with proper ARIA state is usually worth the extra code.
UIXDraft's 180+ template bundle includes responsive navbars — sticky, mega-menu, and mobile-first variants — already wired up with accessible markup.
See the templates →For anything beyond a static template, a real button with aria-expanded communicates menu state to assistive tech in a way the checkbox hack can't:
<button class="nav-burger" aria-expanded="false" aria-controls="nav-menu">
☰
</button>
<ul id="nav-menu" class="nav-links">...</ul>
<script>
const btn = document.querySelector('.nav-burger');
const menu = document.getElementById('nav-menu');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
menu.classList.toggle('open');
});
</script>
position: sticky is simpler than the older fixed-position-plus-body-padding trick, but it needs a background — a transparent sticky nav over scrolling content looks broken the instant text scrolls underneath it:
.nav {
position: sticky;
top: 0;
z-index: 50;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
This pattern — common on content-heavy sites — reclaims vertical space while scrolling down and reappears the moment a user scrolls back up looking for navigation. It needs a small amount of JS since CSS alone has no reliable way to detect scroll direction:
let lastY = 0;
window.addEventListener('scroll', () => {
const y = window.scrollY;
nav.classList.toggle('nav-hidden', y > lastY && y > 80);
lastY = y;
});
.nav {
transition: transform 220ms ease;
}
.nav-hidden {
transform: translateY(-100%);
}
The y > 80 check matters — without it, the nav flickers hidden/shown on the tiniest scroll jitter near the very top of the page.
| Checkbox hack | JS toggle | |
|---|---|---|
| Works with JS disabled | Yes | No |
| Correct ARIA state (aria-expanded) | Difficult | Easy |
| Close on outside click | Not possible in pure CSS | Trivial |
| Best for | Static templates, marketing pages | Apps, anything accessibility-audited |
A dropdown that only opens on :hover is unreachable for keyboard users — there's no hover event when tabbing through links. Combine :focus-within with :hover so the dropdown opens for both interaction methods:
.nav-item {
position: relative;
}
.nav-dropdown {
position: absolute;
top: 100%;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity 160ms ease, transform 160ms ease;
}
.nav-item:hover .nav-dropdown,
.nav-item:focus-within .nav-dropdown {
opacity: 1;
visibility: hidden;
visibility: visible;
transform: translateY(0);
}
Using opacity and visibility together (rather than display: none) keeps the dropdown's contents in the accessibility tree while hidden and lets the transition animate smoothly — display can't be transitioned at all.
For sites with many categories, a mega menu replaces a narrow dropdown list with a wider grid panel. The trick is anchoring it to the full nav width, not just the trigger link:
.nav-item.has-mega {
position: static; /* lets the mega panel anchor to .nav, not the link */
}
.mega-panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
padding: 32px;
background: var(--card);
border-bottom: 1px solid var(--border);
}
Setting position: static on the parent nav item (instead of relative) is the key detail — it lets the mega panel's position: absolute resolve against the outer .nav container instead of the narrow trigger link, so the panel can span the full navbar width.
Highlighting the current page's nav link is usually done by adding an .active class server-side or via JS, but for a static multi-page site, aria-current="page" on the link itself doubles as both the accessible signal and the styling hook:
<a href="/pricing" aria-current="page">Pricing</a>
<style>
.nav-links a[aria-current="page"] {
color: var(--purple);
font-weight: 700;
}
</style>
Either works, but if it's an image, always give it descriptive alt text (or an empty alt="" plus visually hidden text) and wrap it in a link to the homepage. An SVG logo is generally preferable to a raster image since it stays sharp at any screen density without extra srcset work.
This usually happens when a parent element has overflow set to anything other than visible — position: sticky is scoped to its nearest scrolling ancestor, and an overflow: hidden container upstream will silently break it. Check every ancestor up to the body. It's one of the more frustrating CSS bugs to track down precisely because the sticky rule itself is usually correct — the cause is almost always several elements away from where the symptom shows up.
Don't let it wrap awkwardly — either collapse extra items into a "More" dropdown past a certain link count, or switch to the mobile hamburger pattern earlier than the typical 720px breakpoint if the link list is unusually long. A useful test: if the nav needs more than 5–6 top-level items on desktop, it's usually a sign some of them belong grouped under a dropdown rather than sitting at the top level. Trying to force every link into a single visible row usually produces cramped spacing long before it produces genuine wrapping.