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

Tailwind Dashboard Layout: Sidebar, Header and Content Grid

Build a production Tailwind dashboard layout with a collapsible sidebar, sticky header, and responsive content grid — copy-paste React code included.

Dark admin dashboard with sidebar navigation and data grid on screen

Why Dashboard Layout Is Harder Than It Looks

You'd think it's just a sidebar and a main area — slap a flex on a div and ship it. Then week two arrives: the sidebar needs to collapse on mobile, the header should stick while the content scrolls, the grid has to reflow from 3 columns down to 1 without layout shift, and the whole thing has to survive both a 4K monitor and a 360px phone screen. Suddenly 'just a flex div' turns into 30 lines of brittle CSS that breaks the moment a product manager asks for a second nav level.

Tailwind CSS makes this tractable but only if you understand the structural skeleton first. The wrong approach is to open a blank file and start writing utility classes bottom-up. The right approach — and this is what separates production dashboards from tutorial demos — is to design the three zones explicitly: sidebar, header, and content grid. Each zone owns its own scroll context, its own z-index layer, and its own breakpoint behavior.

Honestly, the biggest mistake I see in 2026 is developers using position: fixed for the sidebar when h-screen overflow-y-auto inside a flex container does the job without the coordinate headaches. We'll do it the clean way throughout this guide.

One more thing — this article focuses on pure Tailwind with React. If you want pre-built admin shells with glassmorphism or neobrutalism themes, Empire UI's templates has full-page starters you can clone and modify instead of building from zero.

The Base Structure: Three-Zone Shell

Start with the outer shell. The entire page is a single flex flex-row h-screen overflow-hidden container. That overflow-hidden on the root is doing real work — it prevents the page itself from scrolling and delegates scroll responsibility to specific inner zones.

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen overflow-hidden bg-gray-950 text-gray-100">
      <Sidebar />
      <div className="flex flex-col flex-1 overflow-hidden">
        <Header />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

The inner column — flex flex-col flex-1 overflow-hidden — wraps both the header and the scrollable content. The header doesn't scroll. The <main> does. That's it. No position: sticky, no top-0 tricks needed at this level. The flex column handles the stacking, overflow-hidden on the wrapper clips the scroll to just the main area.

Worth noting: flex-1 on the inner column tells it to consume all horizontal space not taken by the sidebar. If your sidebar is w-64 (256px), the content gets everything else. On a 1440px screen that's 1184px of content width. On a 768px tablet you'll want to collapse the sidebar — we'll handle that next.

Building the Collapsible Sidebar

The sidebar has two jobs: navigate and stay out of the way on small screens. Tailwind's responsive prefixes make the mobile behavior declarative rather than imperative. The sidebar is hidden on mobile and visible from md: up. For the collapsed/expanded toggle on desktop, you manage a boolean in state and conditionally switch between w-64 and w-16.

'use client';
import { useState } from 'react';
import { ChevronLeft, LayoutDashboard, Users, Settings } from 'lucide-react';

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

export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <aside
      className={[
        'hidden md:flex flex-col h-screen bg-gray-900 border-r border-gray-800',
        'transition-all duration-300 ease-in-out',
        collapsed ? 'w-16' : 'w-64',
      ].join(' ')}
    >
      {/* Logo / brand */}
      <div className="flex items-center h-16 px-4 border-b border-gray-800">
        {!collapsed && (
          <span className="text-lg font-semibold tracking-tight">Acme</span>
        )}
        <button
          onClick={() => setCollapsed((c) => !c)}
          className="ml-auto p-1.5 rounded-md hover:bg-gray-800 transition-colors"
          aria-label="Toggle sidebar"
        >
          <ChevronLeft
            className={`w-4 h-4 transition-transform duration-300 ${
              collapsed ? 'rotate-180' : ''
            }`}
          />
        </button>
      </div>

      {/* Nav items */}
      <nav className="flex-1 py-4 space-y-1 px-2">
        {navItems.map(({ icon: Icon, label, href }) => (
          <a
            key={href}
            href={href}
            className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400
                       hover:bg-gray-800 hover:text-white transition-colors"
          >
            <Icon className="w-5 h-5 shrink-0" />
            {!collapsed && (
              <span className="text-sm font-medium">{label}</span>
            )}
          </a>
        ))}
      </nav>
    </aside>
  );
}

The transition-all duration-300 on the aside gives you the slide animation for free. No animation library needed. The shrink-0 on the icon prevents it from squishing during the width transition — a small detail that matters a lot on the 300ms animation.

In practice, the icon-only collapsed state is fine for 3–5 nav items. Once you have 10+ items with nested groups, you probably want a full mobile drawer instead. That's a separate component, but the structural approach stays identical — you're just toggling a translate-x instead of a w- value.

Quick aside: if you want this sidebar with a glassmorphism or cyberpunk visual treatment instead of the default dark gray, check out the Empire UI component library. The sidebar components there ship with style variants you can drop in directly.

Sticky Header with Search and User Menu

The header sits at exactly 64px (h-16) — a number that shows up everywhere in dashboard design because it aligns with most icon grid systems and leaves comfortable touch targets. It's flex items-center with a border-bottom to separate it visually from the content below.

export function Header() {
  return (
    <header className="h-16 shrink-0 flex items-center gap-4 px-6
                       bg-gray-900 border-b border-gray-800 z-10">
      {/* Page title — injected per route via context or props */}
      <h1 className="text-sm font-semibold text-gray-100 mr-auto">
        Dashboard
      </h1>

      {/* Search */}
      <div className="relative hidden sm:block">
        <input
          type="search"
          placeholder="Search..."
          className="w-48 lg:w-64 h-8 pl-9 pr-3 rounded-md text-sm
                     bg-gray-800 border border-gray-700 text-gray-300
                     placeholder:text-gray-500 focus:outline-none
                     focus:ring-2 focus:ring-violet-500 focus:border-transparent"
        />
        <svg className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500"
             fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
      </div>

      {/* Avatar */}
      <button className="w-8 h-8 rounded-full bg-violet-600 flex items-center
                          justify-center text-xs font-bold shrink-0">
        JD
      </button>
    </header>
  );
}

The shrink-0 on the header is critical. Without it, flex column layout can compress the header when the content area gets tall. You don't want a 50px header because the page has a lot of rows in a table.

Notice z-10 on the header. The content scrolls underneath it, and if you have sticky table headers or dropdown menus in the main area, you need this stacking order declared explicitly. The sidebar at the same level doesn't need a z-index because it's in a sibling flex row, not overlapping.

That said, if you're adding a dropdown user menu to the avatar button, give that menu z-50 so it floats above the content properly. Tailwind's z-index scale — 0, 10, 20, 30, 40, 50 — is coarse enough to reason about without a dedicated stacking context spreadsheet.

Responsive Content Grid

This is where most tutorials bail out and just write grid grid-cols-3 gap-6. That works on a 1280px screen and nowhere else. A real dashboard content grid needs to handle four distinct viewport ranges: single-column mobile, two-column tablet, three-column desktop, and four-column wide desktop for stat cards.

// Dashboard overview page
export default function OverviewPage() {
  return (
    <div className="space-y-6">
      {/* Stat cards: 2 cols on sm, 4 cols on xl */}
      <div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
        <StatCard label="Total Users"   value="12,481" delta="+8.2%" />
        <StatCard label="Revenue"       value="$94.2k" delta="+12.5%" />
        <StatCard label="Active Subs"   value="3,204"  delta="-1.1%" />
        <StatCard label="Churn Rate"    value="2.4%"   delta="+0.3%" />
      </div>

      {/* Main chart + sidebar widget: 3/1 split on lg */}
      <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
        <div className="lg:col-span-3 rounded-xl bg-gray-900
                        border border-gray-800 p-6 min-h-[320px]">
          {/* Chart goes here */}
        </div>
        <div className="rounded-xl bg-gray-900 border border-gray-800 p-6">
          {/* Top channels widget */}
        </div>
      </div>

      {/* Full-width table */}
      <div className="rounded-xl bg-gray-900 border border-gray-800 overflow-hidden">
        {/* DataTable goes here */}
      </div>
    </div>
  );
}

function StatCard({
  label, value, delta,
}: { label: string; value: string; delta: string }) {
  const positive = delta.startsWith('+');
  return (
    <div className="rounded-xl bg-gray-900 border border-gray-800 p-5">
      <p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
      <p className="mt-1 text-2xl font-bold text-white">{value}</p>
      <p className={`mt-1 text-sm font-medium ${
        positive ? 'text-emerald-400' : 'text-red-400'
      }`}>
        {delta} vs last month
      </p>
    </div>
  );
}

The lg:grid-cols-4 with lg:col-span-3 pattern gives you a 75/25 split for the chart-plus-widget row. That's a 1440px design standard — the main chart gets about 1080px of horizontal space, which is plenty for a bar or line chart without awkward squishing.

Look, the space-y-6 wrapper on the page is doing quiet but important work. It adds 24px of vertical spacing between each section without you needing to manually set mb-6 on every child. This keeps the vertical rhythm consistent even when sections are conditionally rendered.

For data tables inside the dashboard, always wrap them in overflow-x-auto — not overflow-hidden. Tables routinely exceed their container width on mobile, and you want horizontal scroll rather than clipped content. overflow-hidden on the card's outer border-radius is a separate concern from the table's scroll behavior, so nest the divs accordingly.

If you want to push the visual treatment beyond the default gray, Empire UI's gradient generator and box shadow generator let you dial in card styles that match your brand. You can also apply entire visual themes — try the aurora style over a dark dashboard for a striking effect.

Mobile Drawer: Handling the Hidden Sidebar

The sidebar is hidden md:flex — it doesn't exist on mobile. That means your mobile users need an alternative navigation path. The standard solution is a hamburger button in the header that opens a full-height drawer from the left.

'use client';
import { useState } from 'react';
import { Menu, X } from 'lucide-react';

export function MobileNav() {
  const [open, setOpen] = useState(false);

  return (
    <>
      {/* Trigger button — only visible on mobile */}
      <button
        className="md:hidden p-2 rounded-md hover:bg-gray-800"
        onClick={() => setOpen(true)}
        aria-label="Open navigation"
      >
        <Menu className="w-5 h-5" />
      </button>

      {/* Backdrop */}
      {open && (
        <div
          className="fixed inset-0 bg-black/60 z-40 md:hidden"
          onClick={() => setOpen(false)}
        />
      )}

      {/* Drawer */}
      <div
        className={[
          'fixed inset-y-0 left-0 z-50 w-72 bg-gray-900 border-r border-gray-800',
          'transition-transform duration-300 ease-in-out md:hidden',
          open ? 'translate-x-0' : '-translate-x-full',
        ].join(' ')}
      >
        <div className="flex items-center justify-between h-16 px-4
                        border-b border-gray-800">
          <span className="font-semibold">Acme</span>
          <button onClick={() => setOpen(false)} aria-label="Close navigation">
            <X className="w-5 h-5" />
          </button>
        </div>
        {/* Reuse same nav items from desktop sidebar */}
        <nav className="py-4 px-2 space-y-1">
          {/* ... */}
        </nav>
      </div>
    </>
  );
}

The translate-x-full / translate-x-0 toggle is the right approach here — not display: none. The CSS transition works on transform, not on display, so the slide animation plays correctly. Using hidden to close would make it snap, not slide.

Add the <MobileNav /> button inside your <Header /> component, before the title. It renders only on mobile via md:hidden. Your desktop sidebar stays separate. Don't try to share one component with conditional rendering for both — the structural position in the DOM is different and it creates z-index headaches you don't want.

Performance and Accessibility Checklist

A few things that are easy to miss when you're moving fast. First, keyboard navigation: the sidebar links should be standard <a> elements, not <div onClick>. Screen readers and keyboard users navigate dashboards too, and <a> gives you tab focus and Enter key behavior for free. Same for the hamburger button — use <button>, not a div.

Second, the active route state. Add an aria-current="page" attribute to the currently active nav link. Pair it with a Tailwind variant: aria-[current=page]:bg-gray-800 aria-[current=page]:text-white. That's one line per link and it handles both visual and accessibility state simultaneously.

Third, lazy-load your dashboard page content. The sidebar and header should load immediately. Charts, tables, and heavy widgets can use React's Suspense with a skeleton fallback. This keeps the perceived load time low even if your API calls take 500ms. A skeleton that matches the final card dimensions (h-32 rounded-xl bg-gray-800 animate-pulse) prevents layout shift when data arrives.

Finally, don't neglect the prefers-color-scheme dimension. The dark grays in this guide (gray-900, gray-950) look good in dark mode by default. If your app supports both modes, add dark: variants or use CSS variables for your background and border colors. Tailwind's darkMode: 'class' config with a toggle button on the header is the cleanest approach for 2026 — media-query-based dark mode doesn't give users a choice.

For a production dashboard that needs a full visual system — not just a layout skeleton — browse Empire UI's component library. The neobrutalism and neumorphism style systems both include dashboard-ready card variants with consistent spacing tokens, so you're not inventing padding values from scratch every time.

FAQ

How do I make the sidebar sticky while content scrolls in Tailwind?

Use h-screen overflow-hidden on the root flex container and overflow-y-auto on the main content area. The sidebar sits in the same flex row and naturally stays full height without needing position: sticky or position: fixed.

What's the best Tailwind grid for dashboard stat cards?

Use grid grid-cols-2 xl:grid-cols-4 gap-4 for stat cards — 2 columns on most tablets, 4 on large screens. Avoid starting at 1 column for cards since stat cards are compact enough to sit side-by-side even on a 375px phone.

Should the mobile sidebar be a drawer or a bottom sheet?

Drawer from the left. It mirrors the desktop sidebar position, so muscle memory transfers. Bottom sheets work better for action menus, not persistent navigation.

How do I highlight the active route in a Tailwind sidebar?

Add aria-current='page' to the active link element, then style it with Tailwind's aria-[current=page]:bg-gray-800 aria-[current=page]:text-white variant. It handles both visual and screen-reader state in one attribute.

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

Read next

Tailwind Sidebar Layout: Collapsible, Responsive, AccessibleSidebar Layout in Tailwind: Fixed, Collapsible, Mobile OverlayGlassmorphism Dashboard: Full Admin UI with Frosted-Glass CardsGlassmorphism Sidebar Navigation in React: Frosted Side Panel