EmpireUI
Get Pro
← Blog8 min read#sidebar#navigation#react

Sidebar Navigation in React: Collapsible, Mobile Drawer, Nested

Build a production-ready React sidebar with collapsible panels, mobile drawer behavior, and nested nav — with real code you can copy straight into your project.

Dark UI layout with sidebar navigation panel and nested menu items

Why Sidebar Navigation Is Harder Than It Looks

You've seen the sidebar pattern a thousand times — fixed left column, nav links, maybe a logo at the top. Feels simple. But then product says "make it collapsible on desktop," and "it needs to be a drawer on mobile," and "some items have sub-menus," and suddenly you've got three separate interaction models to reconcile in one component. That's where most implementations fall apart.

The core problem is state. Collapsed vs expanded is one axis. Open vs closed drawer is another. Which nested group is expanded is a third. If you treat these as independent useState calls that don't know about each other, you'll end up with bugs — a desktop collapse that accidentally closes the mobile drawer, or a nested menu that stays open after you've navigated away. Honestly, most sidebar implementations I've seen in production codebases are held together with duct tape by the time they hit real usage.

In this article you're getting three complete patterns: a collapsible desktop sidebar, a mobile slide-in drawer, and nested navigation with accordion-style groups. Each one is self-contained but they compose together cleanly. Worth noting: none of this requires an external nav library — just React 18 hooks and Tailwind CSS.

Quick aside: if you want a head start rather than building from scratch, Empire UI ships styled sidebar primitives you can customize in minutes. That said, understanding the mechanics yourself is worth it — you'll debug faster and customize without breaking things.

Collapsible Desktop Sidebar

The desktop sidebar has two states: expanded (240px wide, labels visible) and collapsed (64px wide, icon-only). The transition between them needs to be smooth — a CSS transition: width 200ms ease on the sidebar container does the job without any animation library. Avoid animating display or visibility; those are instant and look janky.

Here's the base component. It uses a single isCollapsed boolean and exposes a toggle. Icon-only mode hides the label text with overflow-hidden on the sidebar itself — when the width collapses to 64px, the text just disappears. No conditional rendering required, which means no layout shift on open.

// Sidebar.tsx
import { useState } from 'react';
import { ChevronLeft, LayoutDashboard, Settings, Users } from 'lucide-react';

const NAV_ITEMS = [
  { icon: LayoutDashboard, label: 'Dashboard', href: '/dashboard' },
  { icon: Users, label: 'Users', href: '/users' },
  { icon: Settings, label: 'Settings', href: '/settings' },
];

export function Sidebar() {
  const [isCollapsed, setIsCollapsed] = useState(false);

  return (
    <aside
      className={[
        'h-screen bg-gray-950 border-r border-white/10',
        'flex flex-col transition-all duration-200',
        isCollapsed ? 'w-16' : 'w-60',
      ].join(' ')}
    >
      {/* Toggle button */}
      <button
        onClick={() => setIsCollapsed(!isCollapsed)}
        className="ml-auto m-3 p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-white/10"
        aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
      >
        <ChevronLeft
          size={16}
          className={`transition-transform duration-200 ${
            isCollapsed ? 'rotate-180' : ''
          }`}
        />
      </button>

      {/* Nav items */}
      <nav className="flex-1 px-2 overflow-hidden">
        {NAV_ITEMS.map(({ icon: Icon, label, href }) => (
          <a
            key={href}
            href={href}
            className="flex items-center gap-3 px-3 py-2.5 rounded-lg
              text-gray-400 hover:text-white hover:bg-white/10
              transition-colors mb-1"
          >
            <Icon size={20} className="shrink-0" />
            <span
              className={`text-sm font-medium whitespace-nowrap
                transition-opacity duration-150
                ${isCollapsed ? 'opacity-0' : 'opacity-100'}`}
            >
              {label}
            </span>
          </a>
        ))}
      </nav>
    </aside>
  );
}

One thing you'll notice: shrink-0 on the icon prevents it from squishing during the width transition. Without that, icons compress weirdly as the sidebar animates. Small detail, breaks things without it.

In practice, you want to persist the collapsed state to localStorage so it survives page refreshes. Wrap the initial state in a lazy initializer: useState(() => localStorage.getItem('sidebar-collapsed') === 'true') and call localStorage.setItem inside the toggle handler. That's it — no third-party persistence needed.

Mobile Drawer Pattern

On mobile, a fixed 240px sidebar eats your whole screen. The standard fix is a slide-in drawer — hidden off-screen at translateX(-100%), slides in when triggered by a hamburger button, closes when you tap the overlay behind it. This needs to be a completely separate visibility layer from the desktop collapsed state.

The right approach is a media-query-aware split: on md and above, render the sidebar normally. Below md, render it as a fixed overlay with a backdrop. You can do this with Tailwind's responsive prefixes rather than JavaScript window-width checks — which means no layout thrash on initial render.

// MobileDrawer.tsx
import { useEffect } from 'react';

interface MobileDrawerProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

export function MobileDrawer({ isOpen, onClose, children }: MobileDrawerProps) {
  // Prevent body scroll when drawer is open
  useEffect(() => {
    if (isOpen) document.body.style.overflow = 'hidden';
    else document.body.style.overflow = '';
    return () => { document.body.style.overflow = ''; };
  }, [isOpen]);

  return (
    <>
      {/* Backdrop */}
      <div
        className={`fixed inset-0 z-40 bg-black/60 md:hidden
          transition-opacity duration-200
          ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Drawer panel */}
      <div
        className={`fixed inset-y-0 left-0 z-50 w-72 bg-gray-950
          border-r border-white/10 md:hidden
          transition-transform duration-250 ease-out
          ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}
        role="dialog"
        aria-modal="true"
      >
        {children}
      </div>
    </>
  )
}

The pointer-events-none on the invisible backdrop is important — without it, the backdrop captures clicks even when transparent, and your page becomes unresponsive. That's a bug that shows up in every naive implementation.

Wire it up with a useBreakpoint hook or just let CSS handle the hiding. The md:hidden class means the drawer panel only exists in the DOM on small screens. On desktop your normal sidebar takes over. You don't want both rendering simultaneously — it doubles your nav in the accessibility tree. Look, this is the kind of thing that gets flagged in an accessibility audit and takes an afternoon to untangle if you didn't plan for it upfront.

Nested Navigation with Accordion Groups

Nested nav is where things get interesting. You need groups that expand to reveal child links, only one group open at a time (or multiple — product will disagree on this forever), and the active group should auto-open when you land on a child route. That last requirement is what makes nested nav non-trivial.

Here's a clean implementation using a Set for open group IDs — this lets you support either single-open or multi-open with one config flag.

// NestedNav.tsx
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';

interface NavGroup {
  id: string;
  label: string;
  icon: React.ElementType;
  children: { label: string; href: string }[];
}

interface NestedNavProps {
  groups: NavGroup[];
  activeHref: string;
  multiOpen?: boolean;
}

export function NestedNav({ groups, activeHref, multiOpen = false }: NestedNavProps) {
  // Auto-open group containing the active route
  const defaultOpen = groups
    .filter(g => g.children.some(c => c.href === activeHref))
    .map(g => g.id);

  const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpen));

  const toggle = (id: string) => {
    setOpenIds(prev => {
      const next = new Set(multiOpen ? prev : []);
      if (prev.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return (
    <nav className="px-2 space-y-0.5">
      {groups.map(({ id, label, icon: Icon, children }) => {
        const isOpen = openIds.has(id);
        const hasActive = children.some(c => c.href === activeHref);

        return (
          <div key={id}>
            <button
              onClick={() => toggle(id)}
              className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg
                text-sm font-medium transition-colors
                ${hasActive
                  ? 'text-white bg-white/10'
                  : 'text-gray-400 hover:text-white hover:bg-white/10'
                }`}
            >
              <Icon size={18} className="shrink-0" />
              <span className="flex-1 text-left">{label}</span>
              <ChevronDown
                size={14}
                className={`transition-transform duration-150 ${
                  isOpen ? 'rotate-180' : ''
                }`}
              />
            </button>

            {/* Children */}
            <div
              className={`overflow-hidden transition-all duration-200 ${
                isOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
              }`}
            >
              <div className="ml-9 mt-0.5 space-y-0.5">
                {children.map(child => (
                  <a
                    key={child.href}
                    href={child.href}
                    className={`block px-3 py-2 rounded-lg text-sm transition-colors ${
                      child.href === activeHref
                        ? 'text-white bg-white/10'
                        : 'text-gray-500 hover:text-white hover:bg-white/5'
                    }`}
                  >
                    {child.label}
                  </a>
                ))}
              </div>
            </div>
          </div>
        );
      })}
    </nav>
  );
}

The max-h trick for the accordion animation is a CSS-only approach that doesn't require measuring DOM heights. Set max-h-96 (384px) as the open ceiling — make sure no group has more children than that. If you have deeply nested or very long groups, max-h-[500px] works fine. This avoids the useRef + scrollHeight pattern which is more accurate but adds complexity you probably don't need.

That said, max-h animations have one quirk: the animation duration feels uneven between short and tall groups because CSS always animates the full 384px range regardless of actual content height. For most apps this is imperceptible. If you're building something design-heavy — say, a polished dashboard that pairs with Empire UI's glassmorphism components — you might want the scrollHeight approach for pixel-perfect timing.

Composing All Three Together

Now you wire them up. The layout wraps a desktop sidebar and a mobile drawer trigger. A single useSidebar hook owns all state and gets passed down via context — no prop drilling through three levels of layout.

// useSidebar.ts
import { createContext, useContext, useState, ReactNode } from 'react';

interface SidebarContextValue {
  isCollapsed: boolean;
  toggleCollapsed: () => void;
  isMobileOpen: boolean;
  openMobile: () => void;
  closeMobile: () => void;
}

const SidebarContext = createContext<SidebarContextValue | null>(null);

export function SidebarProvider({ children }: { children: ReactNode }) {
  const [isCollapsed, setIsCollapsed] = useState(
    () => localStorage.getItem('sidebar-collapsed') === 'true'
  );
  const [isMobileOpen, setIsMobileOpen] = useState(false);

  const toggleCollapsed = () =>
    setIsCollapsed(prev => {
      localStorage.setItem('sidebar-collapsed', String(!prev));
      return !prev;
    });

  return (
    <SidebarContext.Provider value={{
      isCollapsed,
      toggleCollapsed,
      isMobileOpen,
      openMobile: () => setIsMobileOpen(true),
      closeMobile: () => setIsMobileOpen(false),
    }}>
      {children}
    </SidebarContext.Provider>
  );
}

export const useSidebar = () => {
  const ctx = useContext(SidebarContext);
  if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');
  return ctx;
};

Wrap your root layout with <SidebarProvider>, then pull useSidebar() wherever you need state. Your hamburger button in the mobile header calls openMobile(). The MobileDrawer gets isMobileOpen and closeMobile. The desktop Sidebar gets isCollapsed and toggleCollapsed. Clean separation, zero duplication.

One more thing — active route detection. If you're using Next.js App Router (v14+), grab the active path with usePathname() from next/navigation and pass it as activeHref to your NestedNav. In React Router v6 it's useLocation().pathname. Don't hard-code active states in your nav data — that breaks the moment someone deep-links into the app.

For keyboard navigation, add onKeyDown handlers to your group toggle buttons: Enter and Space should trigger the same toggle as a click. The browser handles this automatically for <button> elements, which is yet another reason to use semantic HTML instead of <div onClick>.

Styling Tips and Common Pitfalls

Active states need to be obvious without being aggressive. A bg-white/10 background with text-white is readable on dark sidebars without screaming at the user. For light-themed sidebars, bg-gray-100 text-gray-900 with a left border accent (border-l-2 border-blue-500) is a clean choice. Either way, make sure the active and hover states are visually distinct — they're often confused in dark-mode implementations.

Overflow is a classic trap. The sidebar container needs overflow-hidden to prevent text from spilling out during the collapse animation. But your nested accordion needs overflow: visible or the children will get clipped. The solution: put overflow-hidden on the outer sidebar width container, not on the nav element itself.

Scroll within the nav is another thing teams get wrong. If your nav list is longer than the viewport, you need overflow-y-auto on the nav element and flex-1 so it expands to fill available space. Combined with a sticky footer (user profile, logout) at the bottom, your structure should be: flex flex-col h-full on the sidebar, flex-1 overflow-y-auto on the nav, and a fixed-height footer div. That pattern holds for 99% of dashboard layouts.

Worth noting: if your app has a dark theme toggle, the sidebar colors need to respect it. Using Tailwind's dark: variants or CSS custom properties for all sidebar colors makes this trivial. Hard-coding bg-gray-950 without a dark-mode strategy means you'll be back refactoring this in three months. For dark-first UI inspiration, the gradient generator and box shadow generator tools on Empire UI are great for generating palette-consistent values without trial-and-error.

FAQ

Should I use a library like Radix or shadcn for sidebar navigation?

For simple cases, you don't need one — the patterns above cover most real-world requirements. That said, shadcn's Sidebar component (added in late 2024) handles a lot of edge cases cleanly if you're already on that stack.

How do I handle sidebar navigation in Next.js App Router without it re-mounting on every page?

Put the sidebar in your root layout.tsx — App Router preserves layout components across page navigations. Use usePathname() inside the sidebar to update active states reactively without a full remount.

What's the right way to animate the mobile drawer in React?

CSS transform: translateX() with a transition is the most performant option — it's GPU-composited and doesn't trigger layout. Avoid animating left, width, or margin as these cause reflows.

How do I make the sidebar accessible for keyboard and screen reader users?

Use <button> for toggle triggers (not divs), add role="dialog" and aria-modal="true" to the mobile drawer, and trap focus inside the drawer when it's open. The focus-trap-react package handles focus trapping in about 5 lines.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Footer Design in React: 5 Patterns From Minimal to Full-FeaturedBreadcrumb Navigation in React: JSON-LD, ARIA and TailwindGlassmorphism Sidebar Navigation in React: Frosted Side PanelMobile Navigation in Tailwind: Bottom Bar, Drawer, Hamburger