Tailwind Responsive Navigation: Desktop Horizontal + Mobile
Build a Tailwind CSS responsive nav that goes horizontal on desktop and collapses to a hamburger on mobile — no extra libraries, just utility classes.
Why Most Tailwind Navbars Break on Mobile
Honestly, most tutorials hand you a horizontal nav that looks fine on a 1440px monitor and then completely falls apart the moment you resize to 375px. You end up with links overflowing horizontally, the logo getting squished, or worse — a hamburger icon that does absolutely nothing because nobody wired it up.
The root problem is thinking desktop-first. Tailwind's breakpoint system — sm:, md:, lg: — adds styles at a minimum width, so it naturally rewards a mobile-first mental model. Start with what works at 320px, then add md:flex, md:space-x-6, and so on. It's a small mindset shift that saves hours of media-query debugging.
This article walks you through a production-ready responsive nav in Tailwind v4.0.2 and React 19. No third-party nav libraries. No 200-line CSS files. Just utility classes and a little state.
Project Setup and the HTML Structure
Assuming you already have a Next.js or Vite project with Tailwind v4.0.2 installed, you need exactly one component file. Create components/Navbar.tsx. The structural skeleton is a <nav> wrapper, a flex row for the logo + hamburger, and a <ul> for the links. That <ul> is hidden on mobile and visible on desktop.
A common mistake is wrapping the whole thing in container mx-auto inside the <nav> tag itself. Don't. Put the container on an inner <div> so your background color or border spans the full viewport width. Sounds obvious, but it trips people up constantly.
Here's a stripped-down shell before we add any toggle logic:
``tsx
export default function Navbar() {
return (
<nav className="w-full bg-neutral-900 border-b border-white/10">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" className="text-white font-bold text-lg tracking-tight">
Empire UI
</a>
{/* Hamburger — mobile only */}
<button className="md:hidden text-white" aria-label="Open menu">
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Desktop links */}
<ul className="hidden md:flex items-center gap-6 text-sm text-neutral-300">
<li><a href="/components" className="hover:text-white transition-colors">Components</a></li>
<li><a href="/templates" className="hover:text-white transition-colors">Templates</a></li>
<li><a href="/blog" className="hover:text-white transition-colors">Blog</a></li>
<li>
<a href="/signup"
className="px-4 py-2 rounded-lg bg-violet-600 text-white hover:bg-violet-500 transition-colors">
Get started
</a>
</li>
</ul>
</div>
</nav>
);
}
``
Adding the Mobile Toggle with useState
The hamburger needs actual state. Wire up useState(false) for isOpen, toggle it on button click, and conditionally render the mobile menu. Nothing fancy — this is exactly 10 lines of logic.
The mobile menu itself should sit below the main bar, full-width, with a solid background so it covers content underneath. Add flex-col gap-2 py-4 px-4 for spacing. You'll want aria-expanded on the button too — screen readers deserve working navs.
``tsx
'use client';
import { useState } from 'react';
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const links = [
{ href: '/components', label: 'Components' },
{ href: '/templates', label: 'Templates' },
{ href: '/blog', label: 'Blog' },
];
return (
<nav className="w-full bg-neutral-900 border-b border-white/10">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" className="text-white font-bold text-lg">Empire UI</a>
<button
className="md:hidden text-neutral-300 hover:text-white"
aria-label="Toggle menu"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? (
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
<ul className="hidden md:flex items-center gap-6 text-sm text-neutral-300">
{links.map(l => (
<li key={l.href}>
<a href={l.href} className="hover:text-white transition-colors">{l.label}</a>
</li>
))}
<li>
<a href="/signup"
className="px-4 py-2 rounded-lg bg-violet-600 text-white hover:bg-violet-500 transition-colors">
Get started
</a>
</li>
</ul>
</div>
{/* Mobile dropdown */}
{isOpen && (
<div className="md:hidden border-t border-white/10 bg-neutral-900">
<ul className="flex flex-col px-4 py-3 gap-1 text-sm text-neutral-300">
{links.map(l => (
<li key={l.href}>
<a
href={l.href}
className="block py-2 px-3 rounded-md hover:bg-white/5 hover:text-white transition-colors"
onClick={() => setIsOpen(false)}
>
{l.label}
</a>
</li>
))}
<li className="pt-2">
<a href="/signup"
className="block text-center py-2 px-4 rounded-lg bg-violet-600 text-white hover:bg-violet-500 transition-colors">
Get started
</a>
</li>
</ul>
</div>
)}
</nav>
);
}
``
Notice the onClick={() => setIsOpen(false)} on each mobile link. Without that, the menu stays open after navigation — annoying on single-page apps where the URL changes but no full page load happens.
Smooth Open/Close Animation Without a Library
Conditional rendering with {isOpen && ...} snaps in and out. For most dashboards that's fine. But if you want a slide-down effect, you don't need Framer Motion for something this simple. CSS grid-template-rows is the cleanest trick: transition from grid-rows-[0fr] to grid-rows-[1fr]. Tailwind v4.0.2 supports arbitrary values so you can write this inline.
Replace the {isOpen && ...} block with this approach:
``tsx
<div
className={[
'md:hidden overflow-hidden transition-[grid-template-rows] duration-200 ease-out grid',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
].join(' ')}
>
<div className="min-h-0 border-t border-white/10 bg-neutral-900">
<ul className="flex flex-col px-4 py-3 gap-1 text-sm text-neutral-300">
{/* same links */}
</ul>
</div>
</div>
``
The inner <div> with min-h-0 is required — without it the grid row trick doesn't clip the overflow correctly. It's one of those things that makes no intuitive sense until you see it work.
Want to pair your nav with a glassmorphism effect? Check out advanced glassmorphism patterns in Tailwind — the backdrop-blur approach there works nicely for sticky navbars on hero sections.
Sticky Positioning and Scroll Behavior
Add sticky top-0 z-50 to the <nav> and you're done for basic sticky behavior. But there's a detail: if your page uses overflow-hidden anywhere in the ancestor chain, position: sticky silently breaks. Debug that before assuming Tailwind is wrong.
A lot of apps want the nav to get a background blur on scroll — transparent when at the top, frosted when scrolled. That takes a scroll event listener or an Intersection Observer. Here's a minimal version using a sentinel element:
``tsx
'use client';
import { useEffect, useRef, useState } from 'react';
export function useScrolled(threshold = 10) {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handler = () => setScrolled(window.scrollY > threshold);
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, [threshold]);
return scrolled;
}
``
Then in your Navbar: const scrolled = useScrolled(); and on the <nav>: className={\sticky top-0 z-50 transition-all duration-200 ${scrolled ? 'bg-neutral-900/90 backdrop-blur-md border-b border-white/10' : 'bg-transparent border-transparent'}\}. Works great on landing pages.
If you're building a theme-toggled nav, the theme toggle in React article covers how to persist dark/light preference across sessions without a flash of unstyled content.
Active Link Highlighting with Next.js usePathname
Desktop navs need active state. In Next.js App Router, usePathname() gives you the current path on the client. Compare it against each link's href and apply conditional classes. Simple, no router dependency beyond what Next already ships.
'use client';
import { usePathname } from 'next/navigation';
// Inside your links map:
const pathname = usePathname();
// In the className:
const isActive = pathname === l.href || pathname.startsWith(l.href + '/');
<a
href={l.href}
className={[
'transition-colors',
isActive
? 'text-white font-medium'
: 'text-neutral-400 hover:text-white',
].join(' ')}
>
{l.label}
</a>One subtlety: pathname.startsWith(l.href + '/') avoids false positives where /blog would match /blog-posts. The trailing slash in the check fixes it. This is the kind of thing that's fine in development and then bites you in production when you add new routes.
You might also want to explore Tailwind container queries if your nav lives inside a layout panel rather than the full viewport — container queries let it respond to its parent width instead of the screen width, which matters more than people expect in sidebar layouts.
Accessibility Checklist Before You Ship
Does your nav actually work without a mouse? Tab through it. Can a keyboard user open the mobile menu, navigate the links, and close it with Escape? A lot of custom navbars fail this test immediately.
Minimum requirements: aria-label on the toggle button, aria-expanded reflecting state, focus trap inside the mobile menu when it's open (or at least focus management back to the trigger on close), and role="navigation" on the <nav> element — though <nav> already has an implicit role, so that last one's technically optional.
Is there a skip-navigation link? If your navbar has 8 items, keyboard users have to tab through all of them to reach main content on every page load. A visually-hidden <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:bg-white focus:text-black focus:px-4 focus:py-2 focus:rounded">Skip to content</a> before the <nav> fixes this in three lines.
For color contrast, check your muted text. text-neutral-400 on bg-neutral-900 is roughly 5.8:1 — passes AA. text-neutral-500 on the same background drops to about 3.7:1 and fails for normal-sized text. These aren't guesses; run them through the WebAIM contrast checker with actual hex values.
Extending the Pattern: Mega Menu and Dropdowns
The pattern above handles flat nav links. What about dropdown menus? The approach is the same: state per dropdown, absolute-positioned panel below the trigger, close on outside click via a useEffect with a document click listener. Don't reach for a library until you've tried it manually — it's maybe 40 lines.
For mega menus with columns of links, consider a CSS-only approach first: group on the parent <li>, group-hover:block on the hidden panel. It works fine for mouse users and degrades okay for keyboard users if you add :focus-within:block too. The CSS-only route has zero JS and no hydration concerns — worth it for marketing sites.
Want to see how Tailwind component patterns handle more complex interactive states? That article breaks down the group, peer, and has- selector patterns that make CSS-driven interactivity viable in more cases than most developers expect.
FAQ
Initialize isOpen to false (which it already is with useState). The flash usually comes from server-rendered HTML showing the menu as visible and then JS hiding it. Since the menu is conditionally rendered client-side via useState, there's no SSR mismatch to worry about — the server renders with isOpen false and the client hydrates the same way.
Both work. hidden md:flex is the classic mobile-first pattern — hides by default, flexes at md and up. max-md:hidden uses the new max-* variant added in Tailwind v3.2 and carried into v4 — it applies only below md. They produce identical results. hidden md:flex is more readable to most developers familiar with the utility-first mental model.
position: sticky requires no ancestor element to have overflow: hidden, overflow: scroll, or overflow: auto set. Use your browser DevTools to inspect the ancestor chain and find the culprit. Often it's a layout wrapper added for horizontal scroll prevention. Replace overflow-hidden with clip-path: inset(0) as an alternative that doesn't break sticky positioning.
Add a useEffect that attaches a click listener to document. Check if the click target is outside your nav ref using ref.current && !ref.current.contains(event.target). If it's outside and the menu is open, call setIsOpen(false). Clean up the listener in the useEffect return. Add a ref to the <nav> element via useRef.
Yes — use two div elements styled as lines, and apply rotate-45 / -rotate-45 with translate-y on the state change. Wrap them in a relative container. It's about 8 lines of JSX and works without any SVG swap logic. Tailwind's transition-transform and duration-200 give you the smooth morph.
Tailwind ships z-50 (z-index: 50) as the highest named utility. For most apps that's enough for the nav. Modals typically use z-[100] or z-[200] and should render in a portal at the end of the body, so they naturally sit above the nav regardless of z-index. If you're stacking a dropdown panel from the nav itself, give it z-[60] so it sits above the nav's z-50 background.