How to Build a Responsive Navbar in HTML, Tailwind CSS & JavaScript

Build an accessible mobile navbar with HTML, Tailwind CSS and vanilla JS, including aria-expanded state, Escape key handling and sticky scroll.

Kavi Dev

How to Build a Responsive Navbar in HTML, Tailwind CSS & JavaScript

Every template in our library starts with the same problem: visitors on a phone need a compact header that stays out of the way, while desktop visitors expect top-level links they can scan in a single glance.

A navigation bar looks simple until you test it with a keyboard, resize the viewport across tablet widths, or scroll over dark and light sections. This guide walks through the exact HTML, Tailwind CSS classes, and vanilla JavaScript we use when building navbar templates so you can adapt the pattern in your own project.

Quick summary: Use a semantic <header> and <nav>, hide the desktop list below lg (1024px), toggle the mobile panel with a <button aria-expanded="false">, and wire up both Escape and link-click handlers so the drawer never gets stuck open.

1. Start with Semantic Markup

A navigation bar is a landmark. Screen readers provide a shortcut menu that jumps directly to <nav> elements, and when a page has more than one navigation region (for example, a header bar and a footer sitemap), the aria-label attribute distinguishes them.

<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
  <nav
    aria-label="Primary"
    class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 lg:px-8"
  >
    <a href="/" class="text-lg font-bold tracking-tight text-slate-900">
      AcmeUI
    </a>

    <!-- Desktop links -->
    <ul class="hidden items-center gap-7 lg:flex">
      <li><a href="/templates" class="text-sm font-medium text-slate-600 hover:text-blue-600">Templates</a></li>
      <li><a href="/components" class="text-sm font-medium text-slate-600 hover:text-blue-600">Components</a></li>
      <li><a href="/docs" class="text-sm font-medium text-slate-600 hover:text-blue-600">Documentation</a></li>
      <li><a href="/pricing" class="text-sm font-medium text-slate-600 hover:text-blue-600">Pricing</a></li>
    </ul>

    <div class="hidden items-center gap-3 lg:flex">
      <a
        href="/download"
        class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
      >
        Download Starter
      </a>
    </div>

    <!-- Mobile toggle button -->
    <button
      id="nav-toggle"
      type="button"
      aria-expanded="false"
      aria-c
      aria-label="Toggle navigation menu"
      class="inline-flex items-center justify-center rounded-lg p-2 text-slate-700 hover:bg-slate-100 lg:hidden"
    >
      <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
      </svg>
    </button>
  </nav>

  <!-- Mobile collapsible panel -->
  <div id="mobile-menu" hidden class="border-t border-slate-200 bg-white px-4 pb-4 pt-2 lg:hidden">
    <ul class="flex flex-col space-y-2">
      <li><a href="/templates" class="block rounded-md px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-100">Templates</a></li>
      <li><a href="/components" class="block rounded-md px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-100">Components</a></li>
      <li><a href="/docs" class="block rounded-md px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-100">Documentation</a></li>
      <li><a href="/pricing" class="block rounded-md px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-100">Pricing</a></li>
    </ul>
  </div>
</header>

Why hidden on the mobile drawer matters

Using the native HTML hidden attribute (or Tailwind's hidden class, which sets display: none) removes closed menu links from the keyboard tab order. A common mistake in animated CSS drawers is hiding the drawer with opacity: 0 and pointer-events: none while leaving it in the document flow. Sighted keyboard users then have to press Tab through invisible mobile links before reaching the main heading.

2. Picking the Right Breakpoint (md vs lg)

Many tutorials collapse the menu below md (768px). In real interfaces, 768px is often too narrow once you include:

  • A logo + brand name (~160px)
  • Five navigation links with 28px gaps (~420px)
  • A theme toggle and primary CTA button (~180px)

That adds up to 760px before container padding, which causes links to wrap onto two lines at 768px–840px. Switching at lg: (1024px) gives tablet portrait users a clean touch drawer and keeps the desktop bar single-line on laptops.

3. Wiring the JavaScript Toggle Properly

A production mobile menu needs three behaviors beyond flipping a class:

  1. Synchronize aria-expanded with the visual state.
  2. Close when the user presses Escape and return focus to the toggle button.
  3. Close automatically if the viewport is resized up to desktop width or an anchor link is clicked.
const toggleButton = document.getElementById('nav-toggle');
const mobileMenu = document.getElementById('mobile-menu');

function setMenuOpen(isOpen) {
  if (!toggleButton || !mobileMenu) return;
  toggleButton.setAttribute('aria-expanded', String(isOpen));
  mobileMenu.hidden = !isOpen;
}

toggleButton?.addEventListener('click', () => {
  const isOpen = toggleButton.getAttribute('aria-expanded') === 'true';
  setMenuOpen(!isOpen);
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape' && toggleButton?.getAttribute('aria-expanded') === 'true') {
    setMenuOpen(false);
    toggleButton.focus();
  }
});

window.matchMedia('(min-width: 1024px)').addEventListener('change', (event) => {
  if (event.matches) setMenuOpen(false);
});

4. Avoiding Sticky Header Layout Pitfalls

When you use sticky top-0 on <header>, two issues show up on real pages:

Overflow on parent containers

position: sticky stops working if any ancestor between <header> and the scrolling viewport has overflow: hidden, overflow: clip, or overflow: auto. If you need to clip decorative background gradients inside your hero section, put overflow-hidden on the <section> element, never on <body class="overflow-x-hidden"> or the outer page wrapper.

Anchor links hidden behind the sticky bar

When a user clicks a table-of-contents link like <a href="#pricing">, the browser scrolls #pricing to the very top of the viewport — directly underneath your 64px sticky header. Fix this globally in your stylesheet with scroll-padding-top:

html {
  scroll-padding-top: 5rem; /* 80px: header height + breathing room */
  scroll-behavior: smooth;
}

Or add Tailwind's scroll-mt-20 utility directly to headings that serve as anchor targets.

Frequently Asked Questions

Why use a button instead of a checkbox hack for a mobile menu?

A native button element lets you update aria-expanded and aria-controls in JavaScript, trap or restore keyboard focus, and close the menu when the user presses Escape. The CSS checkbox hack cannot announce state changes to screen readers reliably.

What breakpoint works best for switching from a mobile drawer to desktop links?

Most navigation bars with 5 to 6 top-level items switch at Tailwind's md (768px) or lg (1024px) breakpoint. If your brand name is long or you include a call-to-action button, lg prevents link wrapping on tablet screens.

Does position: sticky hurt page performance?

No, sticky positioning is handled by the browser compositor. What can cause frame drops is combining backdrop-blur with large box shadows over animated backgrounds on low-end mobile devices.

Follow Us

Share Your Feedback

Did you find this article helpful? We'd love to hear your thoughts and suggestions to improve our content.

Share Feedback