Building a Navbar in HTML and CSS: Sticky Headers, Hamburger Menus, and Accessible Markup

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.

Semantic Markup First

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>

The Desktop Layout: Flexbox, Not Grid

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;
}

A Mobile Hamburger Menu With Zero JavaScript

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.

Skip Rebuilding the Navbar From Scratch

UIXDraft's 180+ template bundle includes responsive navbars — sticky, mega-menu, and mobile-first variants — already wired up with accessible markup.

See the templates →

A JavaScript Toggle With Correct ARIA State

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>

Sticky Without the Jump

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);
}

Hiding the Navbar on Scroll Down, Showing It on Scroll Up

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 vs. JavaScript Toggle

Checkbox hackJS toggle
Works with JS disabledYesNo
Correct ARIA state (aria-expanded)DifficultEasy
Close on outside clickNot possible in pure CSSTrivial
Best forStatic templates, marketing pagesApps, anything accessibility-audited

Dropdowns Need Keyboard Support, Not Just Hover

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.

A Simple Mega Menu Layout

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.

Active Link State Without Extra Classes

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>

Frequently Asked Questions

Should the logo be an image or text?

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.

Why does my sticky navbar flicker or disappear on iOS Safari?

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.

How do I handle a navbar with more links than fit on one line?

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.