Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

February 5, 2024

•

Last updated October 20, 2026

Create a Responsive Tailwind Navbar with Dropdowns

A responsive navbar needs more than a hamburger icon. The mobile menu and dropdown should work with a mouse, touch, and a keyboard, and their buttons should accurately announce whether the content is open.

Let's build a Tailwind navbar that does those things without adding a JavaScript framework. We'll use one set of navigation links at every screen size, a Services dropdown, and a single script to manage both controls.

This example targets Tailwind CSS 4 and its default md breakpoint of 48rem. The quick demo uses Tailwind's @4 browser package, which follows the available 4.x release rather than pinning a patch version. For a real site, use the compiled CSS setup later in the guide.

Demo and original walkthrough

Those links contain the original 2024 implementation. They remain useful for following the visual build, but the complete example below replaces the older interaction code. Don't combine the old and new event handlers.

What we'll build

On smaller screens, a labeled Menu button shows and hides the navigation. At the desktop breakpoint, the links appear in a row and that button disappears.

The Services button controls a nested list of links. It opens on click, Enter, or Space. Escape closes the dropdown and returns focus to the button. A second Escape closes an open mobile menu. Clicking outside or moving keyboard focus away also closes the relevant panel.

We'll keep the HTML semantic: a nav landmark, lists, links, and ordinary buttons. This is website navigation, not an application-style ARIA menu. Adding role="menu" would imply a different interaction model, including additional keyboard behavior. The W3C disclosure navigation example explains this distinction.

Step 1: Start with the complete example

Save the following as index.html and open it in a modern browser with an internet connection.

The Tailwind Play CDN is for development and experimentation only. It lets you try this example without a build step; it isn't the production installation method.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Responsive Tailwind navbar</title>
    <!-- Development demo only. Compile Tailwind CSS for production. -->
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <style>
      /* A hidden panel must stay hidden, even if it has display utilities. */
      [hidden] { display: none !important; }
      /* Keep every link in the normal flow until JavaScript initializes. */
      #site-nav:not([data-enhanced]) #services-links { position: static; }
    </style>
  </head>
  <body class="bg-slate-50 text-slate-900">
    <a href="#main-content"
       class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-white focus:p-3 focus:text-slate-900">
      Skip to content
    </a>

    <nav id="site-nav" aria-label="Primary" class="bg-slate-900 text-white">
      <div class="mx-auto max-w-6xl px-4 md:flex md:items-center md:justify-between md:gap-6">
        <div class="flex min-h-16 items-center justify-between gap-4">
          <a id="brand-link" href="#home"
             class="rounded py-3 text-xl font-bold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-cyan-300">
            Webcrunch
          </a>
          <button id="menu-toggle" type="button" hidden
                  aria-controls="nav-links" aria-expanded="true"
                  class="inline-flex min-h-11 items-center gap-2 rounded px-3 py-2 hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">
            <span id="menu-label">Menu</span>
            <svg aria-hidden="true" focusable="false" class="h-5 w-5"
                 viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M4 6h16M4 12h16M4 18h16" stroke-linecap="round"></path>
            </svg>
          </button>
        </div>

        <ul id="nav-links" class="flex flex-col gap-1 pb-4 md:flex-row md:items-center md:pb-0">
          <li>
            <a href="#home" class="block rounded px-3 py-3 hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">Home</a>
          </li>
          <li>
            <a href="#about" class="block rounded px-3 py-3 hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">About</a>
          </li>
          <li id="services-item" class="relative">
            <button id="services-toggle" type="button" disabled
                    aria-controls="services-links" aria-expanded="true"
                    class="flex w-full items-center justify-between gap-2 rounded px-3 py-3 text-left hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">
              Services
              <svg aria-hidden="true" focusable="false" class="h-4 w-4"
                   viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="m6 9 6 6 6-6" stroke-linecap="round" stroke-linejoin="round"></path>
              </svg>
            </button>
            <ul id="services-links"
                class="space-y-1 rounded-lg bg-slate-800 p-2 md:absolute md:left-0 md:top-full md:z-10 md:mt-2 md:w-52">
              <li><a href="#web-design" class="block rounded px-3 py-3 hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">Web design</a></li>
              <li><a href="#development" class="block rounded px-3 py-3 hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">Development</a></li>
              <li><a href="#seo" class="block rounded px-3 py-3 hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">SEO</a></li>
            </ul>
          </li>
          <li>
            <a href="#contact" class="block rounded px-3 py-3 hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300">Contact</a>
          </li>
        </ul>
      </div>
    </nav>

    <main id="main-content" tabindex="-1" class="mx-auto max-w-6xl space-y-12 px-4 py-12">
      <section id="home" tabindex="-1">
        <h1 class="text-3xl font-bold">A responsive Tailwind navbar</h1>
        <p class="mt-3 max-w-2xl text-slate-600">Resize the window, open Services, and try navigating with only your keyboard.</p>
      </section>
      <section id="about" tabindex="-1"><h2 class="text-2xl font-semibold">About</h2></section>
      <section id="web-design" tabindex="-1"><h2 class="text-2xl font-semibold">Web design</h2></section>
      <section id="development" tabindex="-1"><h2 class="text-2xl font-semibold">Development</h2></section>
      <section id="seo" tabindex="-1"><h2 class="text-2xl font-semibold">SEO</h2></section>
      <section id="contact" tabindex="-1"><h2 class="text-2xl font-semibold">Contact</h2></section>
    </main>

    <script>
      (() => {
        const nav = document.querySelector("#site-nav");
        if (!nav || nav.dataset.enhanced) return;

        const menuToggle = nav.querySelector("#menu-toggle");
        const menuLabel = nav.querySelector("#menu-label");
        const links = nav.querySelector("#nav-links");
        const servicesItem = nav.querySelector("#services-item");
        const servicesToggle = nav.querySelector("#services-toggle");
        const servicesLinks = nav.querySelector("#services-links");
        // Match Tailwind's default md breakpoint. Change both if you customize it.
        const desktop = window.matchMedia("(min-width: 48rem)");
        let mobileOpen = false;
        let servicesOpen = false;

        function setServices(open, returnFocus = false) {
          servicesOpen = open;
          servicesLinks.hidden = !open;
          servicesToggle.setAttribute("aria-expanded", String(open));
          if (returnFocus) servicesToggle.focus();
        }

        function setMobile(open, returnFocus = false) {
          mobileOpen = open;
          const visible = desktop.matches || open;
          links.hidden = !visible;
          menuToggle.setAttribute("aria-expanded", String(visible));
          menuLabel.textContent = open ? "Close menu" : "Menu";
          if (!open) setServices(false);
          if (returnFocus) menuToggle.focus();
        }

        function syncLayout() {
          const active = document.activeElement;
          const focusInLinks = links.contains(active);
          const focusInServices = servicesLinks.contains(active);
          menuToggle.hidden = desktop.matches;
          setMobile(false);

          // Never leave keyboard focus inside a panel or control we just hid.
          if (!desktop.matches && focusInLinks) menuToggle.focus();
          else if (desktop.matches && active === menuToggle) {
            nav.querySelector("#brand-link").focus();
          } else if (desktop.matches && focusInServices) servicesToggle.focus();
        }

        menuToggle.addEventListener("click", () => setMobile(!mobileOpen));
        servicesToggle.addEventListener("click", () => setServices(!servicesOpen));

        nav.addEventListener("keydown", (event) => {
          if (event.key !== "Escape") return;
          if (servicesOpen) {
            event.preventDefault();
            event.stopPropagation();
            setServices(false, true);
          } else if (!desktop.matches && mobileOpen) {
            event.preventDefault();
            event.stopPropagation();
            setMobile(false, true);
          }
        });

        document.addEventListener("click", (event) => {
          if (!nav.contains(event.target) && !desktop.matches && mobileOpen) {
            setMobile(false, links.contains(document.activeElement));
          } else if (!servicesItem.contains(event.target) && servicesOpen) {
            setServices(false, servicesLinks.contains(document.activeElement));
          }
        });

        nav.addEventListener("focusout", (event) => {
          const next = event.relatedTarget;
          if (!next) return;
          if (!nav.contains(next)) setMobile(false);
          else if (!servicesItem.contains(next)) setServices(false);
        });

        // These are same-page demo links. Move focus to their destination before
        // closing a panel, so a hidden link never keeps keyboard focus.
        links.addEventListener("click", (event) => {
          const link = event.target.closest("a");
          if (!link) return;
          const href = link.getAttribute("href");
          if (!href || !href.startsWith("#") || href === "#") return;
          const target = document.getElementById(href.slice(1));
          if (!target) return;
          target.focus();
          setMobile(false);
        });

        desktop.addEventListener("change", syncLayout);
        servicesToggle.disabled = false;
        syncLayout();
        nav.dataset.enhanced = "true";
      })();
    </script>
  </body>
</html>

The sections below the navbar give the demo links real destinations. Replace those sections and links with your site's content when you integrate the component.

Step 2: Understand the responsive layout

Tailwind uses mobile-first styles. The unprefixed classes apply at every width, then md: utilities change the layout at 48rem and above. You can read more in the responsive design documentation.

In the example:

  • flex-col stacks the primary links on mobile.
  • md:flex-row places those same links in a row on wider screens.
  • md:items-center aligns the desktop controls.
  • relative on the Services list item anchors its dropdown.
  • md:absolute, md:left-0, and md:top-full position the desktop dropdown below its button.
  • Without those desktop positioning rules, the mobile dropdown stays in the document flow and pushes the next link down.

JavaScript handles whether a panel is open. CSS handles how it is arranged.

The script's media query matches Tailwind's default breakpoint:

const desktop = window.matchMedia("(min-width: 48rem)");

If you customize Tailwind's md breakpoint, update that query too. Otherwise, the CSS can show a desktop layout while the script still thinks it is on mobile.

Step 3: Keep state and visibility together

There are two pieces of interaction state: mobileOpen and servicesOpen. The setter functions update both the panel's hidden property and the corresponding button's aria-expanded value.

That keeps the visual behavior and the announced state connected. Changing a CSS class alone isn't enough if a screen reader still hears “collapsed” after the panel opens.

Each button also has aria-controls pointing to the exact ID of its panel. The icons are decorative, so they're hidden from assistive technology. The visible Menu and Services text supplies the button names.

The small CSS rule below makes hidden state unambiguous:

[hidden] { display: none !important; }

It prevents a display utility, such as flex, from accidentally making a hidden panel visible. A hidden panel is also removed from the keyboard's tab order.

Why there is only one initialization script

The script sits at the end of the document, after the HTML it references. It can initialize immediately without a second DOMContentLoaded wrapper.

It also marks the navbar once initialization finishes. Running the same initializer again on that same element won't attach another set of listeners.

For a static page, that's enough. If you're moving this into a React, Vue, or Hotwire application, put the setup and teardown into the framework's component lifecycle. In particular, remove document and media-query listeners when the component is removed. Don't paste a new global listener into every page transition.

Step 4: Make closing behavior predictable

The navbar deliberately keeps ordinary keyboard navigation:

  • Tab and Shift+Tab move between available controls and links.
  • Enter and Space activate the native buttons.
  • Escape closes the Services dropdown first and returns focus to Services.
  • If the mobile navigation remains open, another Escape closes it and returns focus to Menu.

Opening the dropdown leaves focus on its button. The next Tab moves to the first available service link. There's no focus trap because this isn't a modal dialog.

When the layout crosses the desktop breakpoint, the script resets the open panels. It also moves focus if the currently focused control is about to disappear. A mobile menu button shouldn't keep keyboard focus after becoming hidden on desktop.

Click handlers use containment checks rather than checking whether the exact clicked element is the button. That means clicking an SVG path or label inside the button still works.

What happens without JavaScript?

The HTML starts with all navigation links available. The mobile toggle is hidden, and the Services button is disabled until the script initializes. The fallback CSS keeps the service links in the normal document flow.

On a production site with compiled CSS, those links remain styled without JavaScript. The CDN demo itself depends on JavaScript to generate Tailwind's styles, so disabling JavaScript there also removes that styling.

What happens when a link is selected?

The example uses same-page destinations such as #web-design. Its link handler moves focus to the destination section before closing the panels. Those sections have tabindex="-1", allowing programmatic focus without adding extra stops to normal Tab navigation.

For regular links to other pages, let the browser navigate normally. If your app uses client-side routing, handle destination focus in the router's navigation lifecycle rather than copying the hash-link handler unchanged.

Step 5: Compile Tailwind for production

If your project already compiles Tailwind, use its existing setup. Copy the component markup and script into the appropriate files and remove the CDN script. Make sure Tailwind scans the files containing the classes.

For a small standalone project with Node.js and npm already installed, the Tailwind CLI workflow is straightforward.

From the folder containing index.html, install the version 4 packages:

npm install -D tailwindcss@4 @tailwindcss/cli@4

Create input.css:

@import "tailwindcss";

Build the stylesheet:

npx @tailwindcss/cli -i ./input.css -o ./output.css --minify

Replace the CDN script in index.html with:

<link rel="stylesheet" href="./output.css">

Keep the small custom CSS rules from the example, either in the page or in your compiled input stylesheet. Deploy the generated CSS with the page, and commit your package lockfile so the installed versions are repeatable.

This tutorial uses the version 4 import syntax. Don't mix it with a version 3 setup guide that expects a different installation workflow. Also check Tailwind's browser compatibility requirements against your audience before choosing a major version.

Step 6: Customize without losing the behavior

Change the colors and spacing

The example uses slate backgrounds and a cyan keyboard-focus outline. Swap the palette to match your site, but check the contrast of normal text, hover states, and focus indicators.

Keep comfortable button and link padding. Making the mobile controls tiny to save space makes them harder to use.

Indicate the current page

On a multi-page site, add aria-current="page" to the link for the current page and give it a distinct visual style. Set it from your router or template rather than leaving Home marked as current everywhere.

Make the navbar sticky

Add these classes to the nav element:

<nav id="site-nav" aria-label="Primary"
     class="sticky top-0 z-40 bg-slate-900 text-white">

That's a change to the existing element, not a second navbar. Test that the sticky header doesn't cover focused content or same-page headings. You may need scroll-margin utilities on your content targets.

Add more dropdowns

Give every trigger and panel a unique ID, and connect each pair with aria-controls. Refactor the script to manage a collection of trigger/panel pairs and close the previous dropdown when another opens.

Don't duplicate the example's IDs or paste its initializer once per dropdown. The code above intentionally manages one Services dropdown.

Test before shipping

A component can look correct at one width and still fail when you navigate without a mouse. Check the integrated page, not just an isolated screenshot.

  1. At a narrow width, confirm that Menu opens and closes the links and its expanded state changes.
  2. Open Services by clicking its text and its icon. Both should work.
  3. Use only the keyboard. Check Tab order, Enter, Space, Escape, and visible focus.
  4. Confirm that hidden links aren't reachable by Tab.
  5. Open a dropdown, then click outside it or tab away. Confirm that it closes without unexpectedly pulling focus back.
  6. Resize across the breakpoint with each panel open and with focus inside the dropdown. No hidden control should retain focus.
  7. Check the layout at 320 CSS pixels, at your desktop width, and with browser zoom increased.
  8. Disable JavaScript and confirm that every navigation destination remains available.
  9. Use a screen reader to check the navigation label, button names, and expanded/collapsed announcements.
  10. Repeat the important checks on an actual touch device and in your supported browsers.

These checks still matter after you change labels, add links, or integrate a routing framework. The sample provides the behavior to build on, not a guarantee of accessibility for every finished site.

Wrapping up

Tailwind handles the layout and visual styling, while a small amount of JavaScript manages disclosure state and focus. Keep those responsibilities clear, preserve the native buttons and links, and test the component in the page where people will actually use it.

For another navigation layout, continue with Code a mega menu with Tailwind CSS.

Link this article
Est. reading time: 14 minutes
Stats: 25,852 views

Collection

Part of the Tailwind CSS collection

Products and courses