EmpireUI
Get Pro
← Blog8 min read#tailwind#sidebar#layout

Tailwind Sidebar Layout: Collapsible, Responsive, Accessible

Build a collapsible, accessible Tailwind sidebar layout from scratch — responsive breakpoints, keyboard nav, ARIA roles, and real code you can drop in today.

code editor open on a dark screen showing a sidebar layout component

Why Sidebar Layouts Are Still Hard in 2026

Every dashboard project eventually hits the same wall. You've got Tailwind set up, your components are looking sharp, and then someone asks for a collapsible sidebar — and suddenly you're juggling transform, transition, z-index, overlay clicks, keyboard traps, and three different breakpoints. It's not hard in isolation. It's hard all at once.

Honestly, most sidebar tutorials online solve one piece well and ignore the others. They give you the slide animation but skip aria-expanded. They handle mobile but assume you don't care about 1024px tablets. You end up duct-taping four Stack Overflow answers together and shipping something that works until a screen reader user files a bug.

This guide builds a sidebar the right way — collapsible on click, responsive across breakpoints, and accessible by default. We'll use Tailwind CSS v3.4 utility classes throughout, with just enough vanilla JS or React state to wire the toggle. No headless UI dependency required, though you can layer it on if you want.

The HTML Structure That Actually Scales

Before touching a single utility class, get the markup right. A sidebar layout has three actors: the outer shell, the sidebar itself, and the main content area. The shell is a flex container. The sidebar and main content are its two children. That's it.

<div class="flex h-screen overflow-hidden bg-gray-950">
  <!-- Sidebar -->
  <aside
    id="sidebar"
    aria-label="Main navigation"
    class="relative flex flex-col w-64 shrink-0 bg-gray-900 transition-all duration-300"
  >
    <nav class="flex flex-col gap-1 p-4" role="navigation">
      <a href="/" class="px-3 py-2 rounded-lg text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500">Home</a>
      <a href="/dashboard" class="px-3 py-2 rounded-lg text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500">Dashboard</a>
      <a href="/settings" class="px-3 py-2 rounded-lg text-gray-300 hover:bg-gray-800 hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500">Settings</a>
    </nav>
  </aside>

  <!-- Main -->
  <main class="flex-1 overflow-y-auto p-6 text-white">
    <!-- your page content here -->
  </main>
</div>

Notice shrink-0 on the aside. Without it, Tailwind's flex layout will let the sidebar compress when content in the main area gets wide — and that ruins the whole illusion. Also overflow-hidden on the wrapper clips the sidebar animation so it doesn't bleed off-screen during transitions.

The h-screen on the outer shell gives you the full viewport height. Pair it with overflow-y-auto on <main> so your content scrolls independently while the sidebar stays fixed in view. Worth noting: if your app has a topbar, swap h-screen for h-[calc(100vh-64px)] — assuming your topbar is 64px.

Collapsible Toggle: Animation Without JavaScript Bloat

The collapse trick with Tailwind is toggling a class. When the sidebar is open, it's w-64. When it's closed, it's w-16 (icon-only mode) or w-0 (fully hidden). The transition-all duration-300 you already put on the aside handles the rest.

// React version — swap for Alpine.js or vanilla JS if you prefer
import { useState } from 'react';

export function SidebarLayout({ children }) {
  const [open, setOpen] = useState(true);

  return (
    <div className="flex h-screen overflow-hidden bg-gray-950">
      <aside
        id="sidebar"
        aria-expanded={open}
        aria-label="Main navigation"
        className={`relative flex flex-col shrink-0 bg-gray-900 transition-all duration-300 ${
          open ? 'w-64' : 'w-16'
        }`}
      >
        <button
          onClick={() => setOpen(!open)}
          aria-controls="sidebar"
          aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
          className="m-2 p-2 rounded-lg text-gray-400 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500"
        >
          {open ? '←' : '→'}
        </button>

        <nav className="flex flex-col gap-1 p-2">
          <a href="/" className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-300 hover:bg-gray-800">
            <span className="text-lg">🏠</span>
            {open && <span className="text-sm">Home</span>}
          </a>
        </nav>
      </aside>

      <main className="flex-1 overflow-y-auto p-6 text-white">
        {children}
      </main>
    </div>
  );
}

The {open && <span>} pattern hides the label text when collapsed, so you get an icon-only rail. This is cleaner than opacity-0 because it removes the text from the DOM entirely — no invisible text confusing screen readers.

In practice, the w-16 collapsed state works better than w-0 for most dashboard UIs. Users can still identify where they are from the icons, and the animation feels less jarring than the sidebar fully disappearing. That said, if you're building a content-heavy editor where screen real estate matters more, w-0 with an overlay toggle is the right call.

One more thing — add will-change: width to your sidebar in your global CSS if you notice jank on lower-end Android devices. Tailwind doesn't set this automatically.

Responsive Breakpoints: Mobile Overlay, Desktop Rail

Mobile is a different contract. On small screens, the sidebar shouldn't push content — it should float over it. That means an overlay pattern: the sidebar is fixed, full-height, and sits on top of the content with a translucent backdrop behind it.

// Combine the two modes with Tailwind responsive prefixes + state

export function SidebarLayout({ children }) {
  const [mobileOpen, setMobileOpen] = useState(false);
  const [desktopOpen, setDesktopOpen] = useState(true);

  return (
    <div className="flex h-screen overflow-hidden bg-gray-950">
      {/* Mobile overlay backdrop */}
      {mobileOpen && (
        <div
          className="fixed inset-0 z-20 bg-black/60 md:hidden"
          onClick={() => setMobileOpen(false)}
          aria-hidden="true"
        />
      )}

      {/* Sidebar — fixed on mobile, relative on desktop */}
      <aside
        aria-label="Main navigation"
        className={`
          fixed md:relative inset-y-0 left-0 z-30
          flex flex-col shrink-0 bg-gray-900
          transition-all duration-300
          ${
            // Mobile: slide in/out
            mobileOpen ? 'translate-x-0' : '-translate-x-full'
          }
          md:translate-x-0
          ${
            // Desktop: collapse to icon rail
            desktopOpen ? 'md:w-64' : 'md:w-16'
          }
          w-64
        `}
      >
        {/* ... nav content ... */}
      </aside>

      {/* Hamburger button — only on mobile */}
      <button
        className="fixed top-4 left-4 z-40 p-2 rounded-lg bg-gray-800 text-white md:hidden"
        onClick={() => setMobileOpen(true)}
        aria-label="Open navigation"
      >
        ☰
      </button>

      <main className="flex-1 overflow-y-auto p-6 md:p-8 text-white">
        {children}
      </main>
    </div>
  );
}

The md:hidden and -translate-x-full combination is the key move. Below 768px, the sidebar starts off-screen and slides in when mobileOpen is true. At md: and above, translate-x-0 always applies and the sidebar sits in the normal document flow.

Quick aside: you might be tempted to use hidden instead of translate-x-full for the closed-on-mobile state. Don't. hidden removes the element from the accessibility tree, which breaks focus management if a user opened the menu via keyboard and then resizes the viewport.

For the desktop collapse button, put it at the top of the sidebar and let it control desktopOpen. The mobile hamburger is a separate button rendered in the topbar area — two separate buttons, two separate state variables, zero confusion.

Accessibility: ARIA, Focus Traps, and Keyboard Nav

Accessibility is where sidebar implementations fall apart. Most tutorials skip it entirely. Then you ship, a screen reader user tweets about it, and suddenly it's a whole thing. Let's not do that.

The minimum viable accessible sidebar needs four things: aria-label on the <nav>, aria-expanded on the toggle button, a focus trap when the mobile overlay is open, and visible focus rings on every interactive element. You've already got the first two from the markup above. Focus rings are handled by focus:ring-2 focus:ring-indigo-500 — just make sure you haven't nuked them with a global *:focus { outline: none } in your CSS (a depressingly common mistake).

// Minimal focus trap hook for the mobile overlay
import { useEffect, useRef } from 'react';

export function useFocusTrap(active) {
  const ref = useRef(null);

  useEffect(() => {
    if (!active || !ref.current) return;

    const focusable = ref.current.querySelectorAll(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    function handleKeyDown(e) {
      if (e.key !== 'Tab') return;
      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }

    // Also close on Escape
    function handleEsc(e) {
      if (e.key === 'Escape') setMobileOpen(false);
    }

    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('keydown', handleEsc);
    first?.focus();

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('keydown', handleEsc);
    };
  }, [active]);

  return ref;
}

Attach this hook to your sidebar <aside> when the mobile menu is open. When a user tabs through all the nav links, focus wraps back to the first one rather than escaping into the background content. Escape closes it. This is the behaviour users expect from every modal-style overlay — the sidebar on mobile is no different.

Look, the ARIA spec for navigation landmarks is pretty clear: one role="navigation" with a distinct aria-label per page is the right pattern. If you have a secondary nav inside the sidebar (say, a sub-menu), give it its own aria-label like aria-label="Settings sub-menu" so screen reader users can distinguish between them. You can also check your implementation against real assistive tech — Empire UI's component demos are all keyboard-tested if you want a reference baseline.

Polishing with Tailwind: Active States, Transitions, and Dark Mode

Active states are the detail that separates a sidebar that looks good from one that feels good. The current page link needs visual contrast from the rest. In Tailwind, that's a simple conditional class — bg-indigo-600 text-white when the route matches, text-gray-300 hover:bg-gray-800 otherwise.

import { usePathname } from 'next/navigation';

function NavLink({ href, icon, label, sidebarOpen }) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <a
      href={href}
      className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
        isActive
          ? 'bg-indigo-600 text-white'
          : 'text-gray-400 hover:bg-gray-800 hover:text-white'
      }`}
    >
      <span className="text-lg shrink-0">{icon}</span>
      {sidebarOpen && <span>{label}</span>}
    </a>
  );
}

For dark mode, you're mostly already there — dark backgrounds and light text is the default sidebar aesthetic. If your app supports dark: and light: variants, make sure the sidebar colors invert cleanly. bg-gray-900 in dark mode becomes something like bg-gray-100 in light mode. Use dark:bg-gray-900 bg-white on the aside and you're done.

Transition on hover is a small thing that reads as polish. The transition-colors class makes the background and text color animate over 150ms — Tailwind's default. That's exactly right; faster and it's invisible, slower and it feels sluggish. Don't change it.

If you want to push the sidebar aesthetic further — glassmorphism panels, gradient accents, or a full neumorphism treatment — Empire UI has pre-built components you can drop in as the sidebar body. The structural code in this article stays the same; you're just swapping the surface styling. Check the gradient generator if you want to build a custom accent for your sidebar header.

Putting It All Together: Final Checklist

Before you call the sidebar done, run through this list. It's the difference between "works on my machine" and "works for everyone".

Checklist: `` ✅ Sidebar uses <aside> with aria-label ✅ Toggle button has aria-controls + aria-expanded ✅ Mobile overlay has backdrop click-to-close ✅ Focus trap active when mobile menu is open ✅ Escape key closes mobile menu ✅ All links have visible :focus-visible rings ✅ Active route link has distinct visual treatment ✅ Sidebar collapses to icon rail on desktop (not just hides) ✅ transition-all duration-300 on the aside for smooth animation ✅ shrink-0 on the aside to prevent flex compression ✅ overflow-hidden on the wrapper to clip transitions ✅ will-change: width added if animation jank observed on mobile ``

That's 12 things. Most sidebar tutorials cover maybe five of them. The ones they skip are always the accessibility ones, which are also the ones that get you in trouble.

Worth noting: this layout pattern works equally well as a base for more visually ambitious designs. Swap the bg-gray-900 for a glassmorphism components surface, add a gradient top section, or pull in one of the templates as your main content area. The structure is clean enough to compose with pretty much anything. Build it once, reuse it across every project.

FAQ

How do I make a Tailwind sidebar fixed while the main content scrolls?

Put h-screen overflow-hidden on the outer flex wrapper, then overflow-y-auto on the <main> element. The sidebar stays in place while only the content area scrolls.

What's the best Tailwind approach for a mobile sidebar overlay vs desktop rail?

Use fixed positioning and -translate-x-full on mobile, toggled via state. At the md: breakpoint, switch to relative positioning and control width (w-64 vs w-16) instead of transform. Two separate state variables makes the logic clear.

Do I need a library like Headless UI for an accessible sidebar?

No. You need aria-expanded on the toggle, aria-label on the nav, a focus trap for the mobile overlay, and Escape-to-close. That's achievable with a small custom hook and no extra dependencies.

Why use w-16 instead of w-0 for the collapsed desktop sidebar state?

w-16 gives you an icon rail so users can still see and access navigation without expanding the sidebar. w-0 makes sense for mobile overlays but feels disorienting on desktop where users expect persistent nav.

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

Read next

Sidebar Layout in Tailwind: Fixed, Collapsible, Mobile OverlayTailwind Dashboard Layout: Sidebar, Header and Content GridGlassmorphism Sidebar Navigation in React: Frosted Side PanelPage Header Variants: 8 Designs for App and Marketing Pages