A navbar is the one component every website needs, and the one most templates get subtly wrong. It looks fine on a laptop. Then on a phone the links overflow, the menu button can’t be reached with a keyboard, or a screen reader announces “button” with no idea what it does.

This tutorial builds a navigation bar that gets the basics right:

  • Responsive: links in a row on wide screens, a hamburger menu on phones.
  • Accessible: a real <button>, aria-expanded to tell assistive technology whether the menu is open, and the Escape key to close it.
  • Lightweight: plain HTML and CSS plus about 20 lines of JavaScript. No framework, no library.

Every snippet below comes from one working file. Copy all three parts into a page and it runs as it is.

The HTML: start with the right elements

<header class="site-header">
  <div class="site-header__inner">
    <a class="site-header__logo" href="/">Lumen</a>
    <nav class="site-nav" aria-label="Main">
      <button class="site-nav__toggle" type="button" aria-expanded="false" aria-controls="site-menu">
        <span class="site-nav__toggle-bar" aria-hidden="true"></span>
        <span class="visually-hidden">Menu</span>
      </button>
      <ul class="site-nav__menu" id="site-menu" hidden>
        <li><a class="site-nav__link" href="/" aria-current="page">Home</a></li>
        <li><a class="site-nav__link" href="/features">Features</a></li>
        <li><a class="site-nav__link" href="/pricing">Pricing</a></li>
        <li><a class="site-nav__link" href="/contact">Contact</a></li>
      </ul>
    </nav>
  </div>
</header>

A few choices here do most of the accessibility work for free:

  • <header> and <nav aria-label="Main"> are landmarks. Screen reader users can jump straight to them, and the label says which navigation this is when a page has more than one.
  • The toggle is a <button>, not a <div> or a link. Buttons are focusable, respond to Enter and Space, and are announced as buttons. A clickable <div> gets none of that without extra work.
  • aria-expanded and aria-controls connect the button to the menu. When the menu opens, the script sets aria-expanded to true and assistive technology announces “expanded”.
  • The button has a text label (“Menu”) that’s visually hidden. Three lines of an icon mean nothing to a screen reader; the word does.
  • aria-current="page" marks the link for the current page, so it can be styled and announced as the current location.
  • The list starts with hidden, so the menu is closed on phones even before the JavaScript runs.

The CSS: mobile first

Write the phone layout first, then add a media query for wider screens. Small screens get the simplest CSS, and you never have to undo desktop styles for mobile.

.site-header {
  --brand: #4f46e5;
  --ink: #0f172a;
  --line: #e2e8f0;
  position: relative;
  background: #fff;
  border-bottom: 1px solid var(--line);
  font-family: system-ui, sans-serif;
}
.site-header__inner {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.site-header__logo {
  font-weight: 800;
  font-size: 20px;
  color: var(--ink);
  text-decoration: none;
}
.site-nav__toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: #fff;
  cursor: pointer;
}
.site-nav__toggle-bar,
.site-nav__toggle-bar::before,
.site-nav__toggle-bar::after {
  display: block;
  width: 18px;
  height: 2px;
  background: var(--ink);
  border-radius: 2px;
  transition: transform 0.2s, opacity 0.2s;
}
.site-nav__toggle-bar { position: relative; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.site-nav__toggle-bar::before,
.site-nav__toggle-bar::after { content: ""; position: absolute; left: 0; }
.site-nav__toggle-bar::before { top: -6px; }
.site-nav__toggle-bar::after { top: 6px; }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar { background: transparent; }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar::before { transform: translateY(6px) rotate(45deg); }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar::after { transform: translateY(-6px) rotate(-45deg); }
.site-nav__menu {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin: 0;
  padding: 8px 20px 20px;
  list-style: none;
  background: #fff;
  border-bottom: 1px solid var(--line);
}
.site-nav__menu[hidden] { display: none; }
.site-nav__link {
  display: block;
  padding: 12px 0;
  color: var(--ink);
  text-decoration: none;
  font-weight: 600;
  border-bottom: 1px solid var(--line);
}
.site-nav__link[aria-current="page"] { color: var(--brand); }
.site-nav__link:focus-visible,
.site-nav__toggle:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
@media (min-width: 768px) {
  .site-nav__toggle { display: none; }
  .site-nav__menu,
  .site-nav__menu[hidden] {
    position: static;
    display: flex;
    gap: 28px;
    padding: 0;
    border: 0;
  }
  .site-nav__link { padding: 8px 0; border: 0; }
  .site-nav__link:hover { color: var(--brand); }
}

What matters here:

  • Custom properties (--brand, --ink, --line) sit on .site-header, not on :root. Rebranding means changing three values, and the header can’t accidentally recolour the rest of your site.
  • The hamburger icon is three bars drawn with CSS: one <span> plus its ::before and ::after. When the button’s aria-expanded is true, the outer bars rotate into an X and the middle bar disappears. The state lives in one attribute and the CSS reads it, so there’s nothing to keep in sync.
  • The 44 × 44px button meets the usual minimum touch-target size, so it’s easy to hit with a thumb.
  • :focus-visible outlines show keyboard users where they are without putting a ring on every mouse click.
  • From 768px up, the toggle disappears and the list becomes a horizontal row. The rule for .site-nav__menu[hidden] inside the media query matters: it keeps the links visible on desktop even if the list still has the hidden attribute.

The JavaScript: about 20 lines

const toggle = document.querySelector('.site-nav__toggle');
const menu = document.getElementById('site-menu');
const desktop = window.matchMedia('(min-width: 768px)');

function setOpen(open) {
  toggle.setAttribute('aria-expanded', String(open));
  menu.hidden = !open;
}
toggle.addEventListener('click', () => setOpen(menu.hidden));
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && !menu.hidden && !desktop.matches) {
    setOpen(false);
    toggle.focus();
  }
});
menu.addEventListener('click', (e) => {
  if (e.target.closest('a') && !desktop.matches) setOpen(false);
});
// Wide screens always show the menu; small screens start with it closed.
function sync() {
  menu.hidden = !desktop.matches;
  toggle.setAttribute('aria-expanded', 'false');
}
desktop.addEventListener('change', sync);
sync();

The script does four small jobs:

  1. Toggle on click. setOpen() updates aria-expanded and hidden in one place, so the two can never disagree.
  2. Close with Escape, then move focus back to the button. Keyboard users expect Escape to close menus, and returning focus means they don’t get lost on the page.
  3. Close after a link is tapped on phones. On single-page sites the page doesn’t reload, so without this the open menu would cover the section you just jumped to.
  4. React to screen-size changes. matchMedia fires when the window crosses 768px (rotating a tablet, for example), so the links are shown on wide screens and the menu starts closed on narrow ones.

Common mistakes to avoid

Hiding the menu with CSS alone. display: none works visually, but then the open or closed state lives only in your stylesheet. With the hidden attribute plus aria-expanded, the state is in the HTML, where browsers and screen readers can see it.

Removing focus outlines. Many CSS resets include outline: none and never add a replacement, so keyboard users can’t see which link they’re on. Style :focus-visible instead of removing outlines.

Making the hamburger a link to #. A link that goes nowhere can jump the page to the top and is announced as a link, not a control. Use a button.

Hover-only dropdowns. If you add submenus later, make them open on click and keyboard focus as well as hover. Touch screens have no hover, and keyboard users can’t trigger it.

Relying on JavaScript to show the menu at all. If the script fails or loads slowly, the navigation should still work. Here the desktop layout is pure CSS, so wide screens always show the links.

How to test your navbar

Before you ship, run through this checklist:

  1. Resize the window from phone to desktop width. The menu should switch layouts cleanly at your breakpoint, with nothing overlapping.
  2. Use only the keyboard. Tab to the menu button, press Enter to open it, Tab through the links, press Escape. Focus should never disappear.
  3. Try a screen reader: VoiceOver on a Mac (Command + F5) or NVDA on Windows. The button should be announced as a collapsed “Menu” button, then as expanded after you open it.
  4. Tap a link on a phone. The menu should close.
  5. Zoom the page to 200%. Text shouldn’t overlap or get cut off.

Taking it further

This navbar covers the essentials. Real sites often need more: a sticky header that stays visible while you scroll, a call-to-action button, a search field, a cart icon for shops, or a mega menu for sites with many pages. Our tutorial on sticky headers with CSS covers the first of these.

If you’d rather start from a finished design, our free HTML header templates include 30 headers built on the same principles: classic layouts, mega menus, shop headers with search, transparent headers and full-screen mobile menus. Each one is a single HTML file, and its CSS is scoped to the header, so it won’t change the rest of your site.

FAQ

Do I need JavaScript for a responsive navbar?

For the open and close toggle on phones, a few lines of JavaScript are the most reliable option. CSS-only versions use a hidden checkbox, but they can’t update aria-expanded or close with Escape, which matters for accessibility.

Which breakpoint should I use?

Use the width where your links stop fitting on one row, not a standard device width. For four or five short links, 768px is a sensible start. Test it and adjust.

Should the mobile menu slide in from the side?

It can, but a menu that drops down under the header is simpler, works with any number of links and doesn’t hide the page behind an overlay. A side drawer makes sense when you have many links or want an app-like feel.

Put a nested <ul> inside the list item, add a <button aria-expanded="false"> next to the parent link to open it, and reuse the same setOpen() pattern. On desktop you can also open it on hover, but keep the button for keyboard and touch users.

htmlcssnavigationaccessibility
Written by

Amir Iqbal

Full-stack developer and the maker of every product in this store. I write about running self-hosted software and the problems our products solve.

About the store