Mobile Navigation in Tailwind: Bottom Bar, Drawer, Hamburger
Three mobile nav patterns — bottom tab bar, slide-out drawer, hamburger menu — built entirely with Tailwind CSS and React. No extra libraries needed.
Why Mobile Navigation Is Still So Easy to Get Wrong
Mobile nav is one of those things that looks trivial on paper and then ruins a launch. You pick a hamburger menu, throw it in the top-right corner, call it a day — and then watch users thrash around trying to figure out what's interactive. This isn't a 2018 problem either. In 2026, with the average thumb reach zone well-documented and iOS safe areas a thing since iPhone X, there's no excuse for nav that fights the hardware.
The three patterns that actually work on mobile are the bottom tab bar, the slide-out drawer, and the hamburger menu. Each solves a different problem. Bottom bars are for apps where users jump between sections constantly — think Instagram or a dashboard. Drawers work when you need secondary navigation that shouldn't eat screen real estate. Hamburger menus are for simple sites with five or fewer top-level links where discoverability matters less than screen space.
Honestly, the choice between them isn't really a design question — it's a content depth question. How many items? How often do users switch? Answer those two things and the pattern picks itself. That said, Tailwind makes all three fast to build and easy to adapt, so let's go through each one properly.
Quick aside: if you want to see these patterns in context of a full component system, Empire UI ships mobile-ready navigation components across all its style variants — glassmorphism, neobrutalism, cyberpunk, and more. Worth browsing before you build from scratch.
Bottom Tab Bar: The Mobile-Native Pattern
The bottom bar is the most thumb-friendly nav you can ship. Apple's Human Interface Guidelines have recommended it since iOS 7 — that's over a decade of user conditioning. Your users already know how it works. Fixed to the bottom of the viewport, it gives you 4–5 slots for primary destinations, and every one of them sits within natural thumb reach on screens up to 6.7 inches.
With Tailwind, the key classes are fixed bottom-0 left-0 right-0 for positioning, pb-safe (or pb-[env(safe-area-inset-bottom)]) to handle the iOS home indicator notch, and grid grid-cols-4 to space your icons evenly. Don't forget z-50 — nothing is more frustrating than a bottom bar that scrollable content slides under.
// BottomTabBar.tsx
import { Home, Search, Bell, User } from 'lucide-react';
import { useState } from 'react';
const tabs = [
{ icon: Home, label: 'Home', href: '/' },
{ icon: Search, label: 'Search', href: '/search' },
{ icon: Bell, label: 'Alerts', href: '/alerts' },
{ icon: User, label: 'Profile', href: '/profile' },
];
export function BottomTabBar() {
const [active, setActive] = useState('/');
return (
<nav
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200
pb-[env(safe-area-inset-bottom)] dark:bg-gray-950 dark:border-gray-800"
>
<div className="grid grid-cols-4">
{tabs.map(({ icon: Icon, label, href }) => (
<button
key={href}
onClick={() => setActive(href)}
className={[
'flex flex-col items-center gap-1 py-3 text-xs font-medium transition-colors',
active === href
? 'text-violet-600 dark:text-violet-400'
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400',
].join(' ')}
aria-current={active === href ? 'page' : undefined}
>
<Icon size={22} strokeWidth={active === href ? 2.5 : 1.75} />
{label}
</button>
))}
</div>
</nav>
);
}Notice strokeWidth changing on the active icon — that's a 0.75px difference, but it reads as "selected" without you needing a dot indicator or color alone. Also worth it: add touch-manipulation to the button className to kill the 300ms tap delay on older Android WebViews. Small thing, huge feel improvement.
One more thing — add mb-16 (or mb-20 if your labels are tall) to your page's main content wrapper so the bottom bar doesn't overlap your last card. It's obvious, but I've seen it missed in production more times than I'd like to admit.
Slide-Out Drawer: Secondary Nav Without the Clutter
Drawers are best when you've got a long list of links — think settings subcategories, an e-commerce filter panel, or a multi-level site map. You don't want to surface all of it at once, but it needs to be accessible without a full page change. The drawer gives you a 280px–320px panel that slides in from the left (or right, your call) and an overlay that dismisses it.
The mechanics in Tailwind are translate-x-full / translate-x-0 on the panel, opacity-0 pointer-events-none / opacity-100 on the overlay, and transition-transform duration-300 ease-in-out for the animation. That's the whole trick. No framer-motion needed for a basic version.
// Drawer.tsx
import { X } from 'lucide-react';
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Drawer({ isOpen, onClose, children }: DrawerProps) {
return (
<>
{/* Overlay */}
<div
onClick={onClose}
className={[
'fixed inset-0 z-40 bg-black/50 transition-opacity duration-300',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')}
aria-hidden="true"
/>
{/* Panel */}
<aside
className={[
'fixed inset-y-0 left-0 z-50 w-72 bg-white dark:bg-gray-950',
'shadow-xl flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : '-translate-x-full',
].join(' ')}
aria-label="Navigation drawer"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800">
<span className="font-semibold text-gray-900 dark:text-white">Menu</span>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-4">{children}</nav>
</aside>
</>
);
}Why w-72 (288px) and not the classic w-80 (320px)? On a 375px wide iPhone SE, 320px leaves only 55px of visible page behind the overlay — barely enough to see the close affordance. 288px gives you a more comfortable peek. In practice, anything between 260px and 300px works; just avoid going full-width on phones or it reads as a page, not a drawer.
Keyboard trap your drawer if you care about accessibility. When isOpen flips true, focus the close button. Bind Escape to onClose. These two lines cover most of WCAG 2.2 Criterion 2.1.2. You don't need a full focus-trap library for a simple drawer — a useEffect that does closeButtonRef.current?.focus() on mount is fine.
Hamburger Menu: When Simplicity Wins
The hamburger has been declared dead every year since 2014 and it's still everywhere. Why? Because for a marketing site with five links, it's the right call. Don't over-engineer it. The icon is universally understood by 2026, the pattern takes 30 lines to implement, and it gets completely out of the way when the user's reading content.
The clean Tailwind approach is a max-h-0 overflow-hidden to max-h-96 transition on the menu panel. Some developers prefer hidden / block toggling, but max-h gives you a smooth height animation without JavaScript calculating element heights. The downside: if your menu grows past 384px (Tailwind's 96 token = 24rem), the content clips. Use max-h-[500px] or a specific value if your link list is long.
// HamburgerMenu.tsx
import { useState } from 'react';
import { Menu, X } from 'lucide-react';
const links = [
{ label: 'Home', href: '/' },
{ label: 'Features', href: '/features' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Blog', href: '/blog' },
{ label: 'Contact', href: '/contact' },
];
export function HamburgerMenu() {
const [open, setOpen] = useState(false);
return (
<header className="sticky top-0 z-40 bg-white border-b border-gray-100 dark:bg-gray-950 dark:border-gray-800">
<div className="flex items-center justify-between px-4 h-14">
<a href="/" className="font-bold text-lg text-gray-900 dark:text-white">
YourBrand
</a>
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label="Toggle menu"
className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{open ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
{/* Collapsible nav */}
<nav
className={[
'overflow-hidden transition-[max-height] duration-300 ease-in-out border-t border-gray-100 dark:border-gray-800',
open ? 'max-h-96' : 'max-h-0',
].join(' ')}
>
<ul className="py-2">
{links.map(({ label, href }) => (
<li key={href}>
<a
href={href}
className="block px-5 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50
dark:text-gray-300 dark:hover:bg-gray-800/60"
onClick={() => setOpen(false)}
>
{label}
</a>
</li>
))}
</ul>
</nav>
</header>
);
}Look, aria-expanded on the toggle button isn't optional — screen readers announce "button, expanded" or "button, collapsed" automatically when this attribute is present. Takes two seconds to add. There's no good reason to skip it.
One pattern worth adopting: close the menu on route change. In Next.js, import usePathname from next/navigation and fire setOpen(false) in a useEffect that watches it. Without this, users on a single-page app click a link, the page updates, and the hamburger panel just... stays open. It's a subtle bug that feels janky and is really common.
Responsive Switching: Desktop Nav + Mobile Nav Together
The real implementation challenge isn't any one pattern in isolation — it's hiding the desktop nav on mobile and surfacing the mobile nav on desktop without layout flicker. Tailwind's responsive prefix system (md:, lg:) handles this cleanly, but you need to be deliberate about which element exists in the DOM at which breakpoint versus which one is just hidden.
The right approach is to render both nav variants and use hidden md:flex / md:hidden to swap between them. Don't conditionally mount with JavaScript based on window.innerWidth — that causes a flash after hydration on SSR pages. Tailwind's CSS-only hide/show is instantaneous because it's applied during the first paint.
// ResponsiveNav.tsx
export function ResponsiveNav() {
return (
<>
{/* Desktop nav — hidden below md (768px) */}
<nav className="hidden md:flex items-center gap-6 px-8 h-16 border-b border-gray-100">
<a href="/" className="font-bold text-lg">YourBrand</a>
<div className="flex items-center gap-4 ml-auto text-sm font-medium">
<a href="/features" className="text-gray-600 hover:text-gray-900">Features</a>
<a href="/pricing" className="text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/blog" className="text-gray-600 hover:text-gray-900">Blog</a>
<a href="/pricing" className="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm">
Get started
</a>
</div>
</nav>
{/* Mobile hamburger — hidden above md */}
<div className="md:hidden">
<HamburgerMenu />
</div>
{/* Mobile bottom bar — hidden above md */}
<div className="md:hidden">
<BottomTabBar />
</div>
</>
);
}Worth noting: Tailwind's default md breakpoint is 768px. If your design calls for a different switchover — say, 640px for compact layouts — you can override it in tailwind.config.ts under theme.screens. That said, 768px matches the iPad mini breakpoint and works for the vast majority of projects without adjustment.
If you want to push the visual design of any of these patterns beyond basic utility classes, Empire UI has a whole collection of styled navigation components — including the glassmorphism frosted-glass navbar that looks genuinely stunning on dark backgrounds. You can grab any of them and slot them into the responsive shell above.
Handling iOS Safe Areas and Android Gesture Zones
Safe areas are where most mobile nav implementations fall apart. The iPhone 15 Pro has a 34px bottom safe area inset. On Androids with gesture navigation (Android 10+), the gesture zone typically sits 20px–48px from the bottom edge. Your bottom tab bar absolutely must account for both or your last icon will sit partially behind system UI.
Tailwind doesn't ship safe-area utilities by default, but you can use arbitrary values: pb-[env(safe-area-inset-bottom)]. To make this cleaner, add it to your Tailwind config as a custom utility or use the tailwindcss-safe-area plugin (it's 2kb, widely used, and adds pt-safe, pb-safe, pl-safe, pr-safe classes).
// tailwind.config.ts
import safeAreaPlugin from 'tailwindcss-safe-area';
export default {
plugins: [safeAreaPlugin],
};Then your bottom bar just needs pb-safe added alongside your existing padding classes. Done. For the drawer and hamburger, top safe areas matter too — pt-safe on your header prevents the toggle button from sitting under the Dynamic Island or status bar on notched iPhones.
In practice, I'd also add a min-h-[52px] to the bottom bar so it never collapses smaller than 52px even when env(safe-area-inset-bottom) resolves to zero on desktop or older Android. Defensive sizing like this saves you a QA round.
Animations, Active States, and Dark Mode
Animation is where mobile nav goes from feeling like a website to feeling like an app. The bar for "feels native" is higher than it's ever been — users have been on polished apps since 2010, and a nav that just jumps open reads as unfinished. Three transitions matter most: the hamburger icon morph, the drawer slide, and the active tab indicator.
For the hamburger-to-X icon morph, the cleanest approach is two SVG paths with transition-transform and transition-opacity. But honestly, swapping between <Menu> and <X> from lucide-react with a transition-all duration-200 on the button container is 80% as good and takes two minutes. Ship the simple version first.
Active tab indicators deserve more thought. The colored icon + bold stroke approach from the bottom bar code above works. You can level it up with a small pill indicator above the icon using absolute -top-px left-1/2 -translate-x-1/2 h-0.5 w-6 rounded-full bg-violet-600 — but only show it when the tab is active. That's a single conditional className. No animation library needed.
Dark mode across all three patterns is straightforward with Tailwind's dark: prefix. The key tokens to set: background (dark:bg-gray-950 or dark:bg-gray-900), border (dark:border-gray-800), icon/text color (dark:text-gray-300), and overlay (bg-black/60 already works in both modes). The gradient generator and box shadow generator are useful for tweaking the visual weight in dark mode without guessing at values — generate a value, paste the CSS directly, or map it to an arbitrary Tailwind class like shadow-[0_4px_24px_rgba(0,0,0,0.4)].
Worth noting: if you're building a glass-style mobile nav — frosted bottom bar, blurred drawer panel — the same backdrop-blur-md bg-white/10 border-white/20 recipe from glassmorphism components applies directly. The bottom bar actually looks excellent as frosted glass over a gradient background. It's one of those combinations that lands immediately.
FAQ
Bottom tab bar for apps where users switch sections frequently (4–5 primary destinations). Drawer for secondary or hierarchical nav with many links. Hamburger for simple marketing sites with 5 or fewer top-level pages.
Use pb-[env(safe-area-inset-bottom)] as an arbitrary value, or install the tailwindcss-safe-area plugin for the cleaner pb-safe utility class. Either approach works — the plugin is easier to maintain across a project.
Yes. Use transition-transform duration-300 ease-in-out with toggling between translate-x-0 and -translate-x-full. That's pure CSS via Tailwind — no JavaScript animation library required for a basic slide-in drawer.
Render both nav components in the DOM and use Tailwind's hidden md:flex / md:hidden to swap between them via CSS. Avoid JavaScript that reads window.innerWidth on mount — that fires after hydration and causes a visible layout flash on SSR pages.