Sidebar Layout in Tailwind: Fixed, Collapsible, Mobile Overlay
Build a production-ready Tailwind sidebar: fixed desktop rail, collapsible panel, and a mobile overlay drawer — no JS library needed.
Why Sidebar Layouts Are Still Painful in 2026
Dashboard apps, admin panels, docs sites — they all need a sidebar. And yet it's one of those things developers rebuild from scratch every single project because there's no "one true pattern." Is it fixed-position? Does it push content or overlay it? What happens on a 375px screen? The answers differ for every product.
Tailwind gives you the building blocks, but it doesn't make the decisions for you. That's actually a good thing, but it means you need a clear mental model before writing a single class. This guide covers three distinct modes — fixed rail, collapsible panel, and mobile overlay — and how to combine them into one component that handles all screen sizes without reaching for a UI library.
Honestly, most sidebar bugs come from mixing position: fixed and position: sticky without understanding how they interact with scroll containers. We're going to be explicit about that from the start so you don't hit a wall two sprints in.
Worth noting: everything here works with Tailwind v3.4+ and vanilla React. No Headless UI, no Radix, no extra dependencies — just state, some refs, and class toggling. If you want pre-built components that already look great, browse components on Empire UI.
The Base Shell: Fixed Sidebar + Scrollable Main
The most common desktop pattern is a sidebar that stays fixed on the left while the main content area scrolls independently. The trick is getting the layout container right — if you mess up the overflow or height here, you'll fight scrolling issues forever.
Here's the shell you want:
``jsx
// layouts/AppShell.jsx
export function AppShell({ children, sidebar }) {
return (
<div className="flex h-screen overflow-hidden bg-zinc-950">
{/* Fixed sidebar column */}
<aside className="w-64 shrink-0 border-r border-white/10 overflow-y-auto">
{sidebar}
</aside>
{/* Scrollable main */}
<main className="flex-1 overflow-y-auto px-6 py-8">
{children}
</main>
</div>
)
}
``
The key classes: h-screen overflow-hidden on the outer wrapper prevents the page from scrolling — it's the flex children that scroll instead. shrink-0 on the sidebar keeps it from flexing narrower than w-64 (256px) when content pushes against it. flex-1 overflow-y-auto on main does the actual scrolling.
Quick aside: don't use position: fixed for the sidebar unless you're attaching it to the viewport and offsetting your main with a left margin. The flex approach above is cleaner, easier to reason about, and doesn't break when you add a top nav bar later.
For the sidebar width itself, w-64 (256px) is a solid default for content-heavy nav. If you're doing an icon rail, drop it to w-16 (64px). You can always parameterize this with a prop and a dynamic class — just don't use arbitrary values like w-[220px] unless you're genuinely designing to a spec that requires it.
Collapsible Sidebar: Toggle Between Full and Icon Rail
The collapsible pattern is popular in admin dashboards — you get a full 256px sidebar by default, and clicking a toggle shrinks it to a 64px icon rail. Labels hide, icons stay. It's slick when done right.
The cleanest way to implement this in Tailwind is with a CSS transition on width, not transform. Width transitions feel more natural here because the content layout actually reflows:
``jsx
// components/Sidebar.jsx
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export function Sidebar({ items }) {
const [collapsed, setCollapsed] = useState(false)
return (
<aside
className={
flex flex-col border-r border-white/10 overflow-hidden
transition-all duration-200 ease-in-out
${collapsed ? 'w-16' : 'w-64'}
}
>
{/* Toggle button */}
<div className="flex items-center justify-end p-2">
<button
onClick={() => setCollapsed(v => !v)}
className="p-1.5 rounded-md hover:bg-white/10 text-white/60 hover:text-white"
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
{/* Nav items */}
<nav className="flex-1 space-y-1 px-2 py-2">
{items.map(item => (
<a
key={item.href}
href={item.href}
className="flex items-center gap-3 px-2 py-2 rounded-lg
text-white/60 hover:text-white hover:bg-white/10
transition-colors"
>
<item.Icon size={18} className="shrink-0" />
{!collapsed && (
<span className="text-sm font-medium truncate">{item.label}</span>
)}
</a>
))}
</nav>
</aside>
)
}
``
The !collapsed && conditional rendering is the right call over opacity-0 or hidden — hiding with CSS keeps the element in the DOM and you get layout glitches if the hidden text wraps before the transition finishes. Conditional rendering avoids that entirely.
In practice, you'll want to persist the collapsed state somewhere — localStorage is fine for a user preference. Wrap setCollapsed to write to storage and read the initial value from storage with a lazy useState initializer. That way the sidebar doesn't flash open on every page reload.
One more thing — add title={item.label} on each link when collapsed so keyboard and mouse users still get a tooltip. It's a 10-second change and it matters more than you'd think.
Mobile Overlay: Drawer on Small Screens
On mobile, you don't want a sidebar taking up horizontal space — you want a drawer that slides in from the left, overlays the content, and can be dismissed. The pattern has three parts: a trigger button, the drawer itself, and a backdrop.
Here's the component:
``jsx
// components/MobileDrawer.jsx
import { useEffect } from 'react'
export function MobileDrawer({ open, onClose, children }) {
// Close on Escape
useEffect(() => {
const handler = (e) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
className={
fixed inset-0 z-40 bg-black/60 backdrop-blur-sm
transition-opacity duration-200
${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
}
/>
{/* Drawer panel */}
<aside
className={
fixed inset-y-0 left-0 z-50 w-72 bg-zinc-900
border-r border-white/10 overflow-y-auto
transition-transform duration-200 ease-in-out
${open ? 'translate-x-0' : '-translate-x-full'}
}
>
{children}
</aside>
</>
)
}
``
Using translate-x-0 vs -translate-x-full with a transition-transform is the right move for performance — GPU-composited transforms don't cause layout reflows. Avoid animating left or margin-left, those are expensive.
The pointer-events-none on the backdrop when closed is important. Without it, the invisible backdrop intercepts clicks even when the drawer is shut, which will drive you absolutely mad during QA.
For the trigger, a hamburger button you show only on md:hidden (or lg:hidden depending on your breakpoint strategy) works well:
``jsx
// In your top nav
<button
onClick={() => setDrawerOpen(true)}
className="lg:hidden p-2 rounded-md hover:bg-white/10 text-white"
aria-label="Open menu"
>
<Menu size={20} />
</button>
``
Combining All Three: The Responsive AppShell
So you've got three patterns — fixed rail, collapsible panel, mobile overlay. How do you combine them without writing three separate components? The answer is responsive classes and a single source of state.
Here's the full AppShell that handles all breakpoints:
``jsx
// layouts/AppShell.jsx
import { useState } from 'react'
import { Sidebar } from '../components/Sidebar'
import { MobileDrawer } from '../components/MobileDrawer'
import { Menu } from 'lucide-react'
export function AppShell({ navItems, children }) {
const [drawerOpen, setDrawerOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="flex h-screen overflow-hidden bg-zinc-950 text-white">
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden lg:block">
<Sidebar
items={navItems}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(v => !v)}
/>
</div>
{/* Mobile drawer — only mounts when open */}
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Sidebar items={navItems} collapsed={false} />
</MobileDrawer>
{/* Main content */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* Top bar — only visible on mobile */}
<header className="lg:hidden flex items-center gap-4 px-4 h-14
border-b border-white/10">
<button
onClick={() => setDrawerOpen(true)}
className="p-2 rounded-md hover:bg-white/10"
aria-label="Open menu"
>
<Menu size={20} />
</button>
<span className="font-semibold text-sm">My App</span>
</header>
<main className="flex-1 overflow-y-auto px-6 py-8">
{children}
</main>
</div>
</div>
)
}
``
The hidden lg:block / lg:hidden split does the heavy lifting. Below lg (1024px), the drawer is what users interact with; above it, the desktop sidebar takes over. The MobileDrawer component stays in the DOM but the drawer panel is off-screen, so there's no flash on resize.
Look, you could make this more complex — nested nav groups, active state highlighting, drag-to-resize. But this skeleton handles 80% of real-world dashboard requirements. Add complexity when you have a specific requirement, not speculatively.
For styling the active nav item, add an isActive check against window.location.pathname or use your router's built-in active detection:
``jsx
className={... ${isActive ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white'}}
`
Keep the active state visually distinct but subtle — a 10% white background (bg-white/10`) with full white text is readable without being flashy. If you want something more expressive, check out the glassmorphism components on Empire UI for frosted-glass sidebar cards.
Handling Edge Cases: Nested Routes, Scroll Position, Focus Trapping
The basic shell works, but production always surfaces edge cases. Three common ones worth addressing now so they don't bite you later.
Nested navigation — when sidebar items have sub-menus — usually means a disclosure pattern. The simplest Tailwind approach:
``jsx
function NavGroup({ label, icon: Icon, children }) {
const [open, setOpen] = useState(false)
return (
<div>
<button
onClick={() => setOpen(v => !v)}
className="flex w-full items-center gap-3 px-2 py-2 rounded-lg
text-white/60 hover:text-white hover:bg-white/10 transition-colors"
>
<Icon size={18} className="shrink-0" />
<span className="flex-1 text-sm text-left">{label}</span>
<ChevronDown
size={14}
className={transition-transform ${open ? 'rotate-180' : ''}}
/>
</button>
{open && (
<div className="ml-7 mt-1 space-y-1 border-l border-white/10 pl-3">
{children}
</div>
)}
</div>
)
}
``
Scroll position — when a user navigates to a new page, you generally want the sidebar scroll to stay put (the nav item they clicked is still visible) but the main content to scroll to top. The overflow-y-auto on each container independently handles this without any JavaScript.
Focus trapping in the mobile drawer is the one you'll forget. When the drawer opens, focus should move into it and be trapped until it closes — otherwise keyboard users Tab out into the background content. The quickest approach without a library:
``jsx
// Add to MobileDrawer, runs when open changes to true
useEffect(() => {
if (!open) return
const drawer = drawerRef.current
const focusable = drawer.querySelectorAll(
'a, button, [tabindex]:not([tabindex="-1"])'
)
focusable[0]?.focus()
}, [open])
`
That's not a full ARIA trap, but it's enough for most apps. For WCAG 2.2 compliance, look into focus-trap-react` — it's ~4kb and handles every edge case.
Performance and Accessibility: The Stuff You Skip in Prototypes
Two things developers consistently skip when building sidebars in a hurry: aria-current on active nav links, and keeping the sidebar out of the tab order when it's off-screen on mobile.
For active links, aria-current="page" is the correct attribute:
``jsx
<a
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={...}
>
``
Screen readers announce this, which helps users understand where they are without visual context. It's literally a one-word addition.
For the mobile drawer, when it's closed, add aria-hidden="true" to the aside and tabIndex={-1} to all focusable children inside it. That stops keyboard users from accidentally tabbing into a hidden sidebar. You can do this by passing inert (now widely supported as of 2024 in all major browsers) to the drawer when closed:
``jsx
<aside
inert={!open ? '' : undefined}
className={...}
>
`
The inert attribute removes the entire subtree from the accessibility tree and tab order in one shot. Way cleaner than managing tabIndex` manually.
On the performance side, the transition-transform and transition-all on the sidebar are cheap, but if you're rendering a very long nav list inside it, consider virtualization. That's overkill for most dashboards, but if you're building something like a file explorer with thousands of items, look at react-virtual.
If you're after a more distinctive visual style for your sidebar, try pulling inspiration from Empire UI's style collections — the neobrutalism components in particular work surprisingly well for bold dashboard nav. And if you need a quick color palette for your sidebar background, the gradient generator is handy for exploring dark gradients without leaving the browser.
FAQ
Flexbox is almost always better. Fixed positioning means you have to manually offset the main content with ml-64 and track that value everywhere. The flex h-screen overflow-hidden pattern keeps the sidebar and content in the same flow, so resizing and adding a top nav just works without hacks.
Add overflow-hidden to document.body when the drawer opens and remove it when it closes. Do this inside a useEffect that watches the open state. Alternatively, the inert attribute on background content also stops scroll propagation in modern browsers.
The lg breakpoint (1024px) is the standard for dashboards because at md (768px) tablets in landscape mode still feel cramped with a 256px sidebar. That said, if your content is very narrow, md is fine — it's your product, run a quick usability test and pick what feels right.
Yes, and you should. Use transition-all duration-200 with w-64 vs w-16 on the sidebar element. Avoid toggling hidden because it skips the transition entirely — the element disappears instantly. Width transitions let the layout animate smoothly without JavaScript animation libraries.