EmpireUI
Get Pro
← Blog7 min read#dark-mode#sidebar-navigation#admin-dashboard

Dark SaaS Sidebar: Navigation Component for Admin Dashboards

Build a dark SaaS sidebar for admin dashboards with React and Tailwind v4. Collapsible nav, icon slots, active states — no fluff, just working code.

Dark admin dashboard interface showing a sidebar navigation with icon labels and active state highlighting

Why the Sidebar Is the Most Opinionated Part of Any Admin Dashboard

Honestly, the sidebar makes or breaks an admin dashboard more than any other component. Users spend the entire session inside it — hovering, scanning, collapsing it when they need screen space. A bad sidebar is invisible friction that compounds over thousands of daily interactions.

Dark SaaS sidebars have become the de-facto standard for internal tools, analytics platforms, and control panels. The aesthetic is functional: dark backgrounds reduce eye strain during long work sessions, active states pop without neon-level contrast, and icon-only collapse modes actually save meaningful horizontal real estate.

This article walks you through building a production-grade dark sidebar component using React and Tailwind v4.0.2. We're covering structure, active state management, collapse behavior, and the CSS specifics you'll actually need — not a watered-down demo.

The Anatomy of a Dark SaaS Sidebar

A sidebar has four distinct zones. The brand/logo area at the top. The nav item list in the middle. An optional secondary section (settings, help, account) near the bottom. And the footer — usually user avatar plus sign-out. Miss any of these and the sidebar feels incomplete even if users can't articulate why.

Nav items themselves carry more complexity than they look. Each one needs a default state, a hover state, an active/current state, and sometimes a badge (unread notifications, error count). The dark palette makes active states natural: a slightly lighter background fill — something like rgba(255,255,255,0.08) — is enough contrast without screaming at the user.

Collapse behavior is where most implementations fall apart. You have two patterns: icon-only mode (the sidebar shrinks to about 64px and only icons show) and full hide (sidebar slides off-canvas). For admin dashboards, icon-only is almost always better. Users can still navigate without re-opening a drawer.

Setting Up the Component Structure in React

The sidebar is a controlled component. It receives collapsed as a prop (or manages it locally with useState) and exposes an onToggle callback. Nav items come in as a typed array so you can render them dynamically — no hardcoded JSX lists.

Here's a minimal but complete implementation you can drop into any React + Tailwind project:

import { useState } from 'react';
import { LucideIcon } from 'lucide-react';

type NavItem = {
  label: string;
  href: string;
  icon: LucideIcon;
  badge?: number;
};

type SidebarProps = {
  items: NavItem[];
  activeHref: string;
};

export function DarkSaasSidebar({ items, activeHref }: SidebarProps) {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <aside
      className={[
        'flex flex-col h-screen bg-[#0f1117] border-r border-white/10',
        'transition-all duration-200 ease-in-out',
        collapsed ? 'w-16' : 'w-60',
      ].join(' ')}
    >
      {/* Logo zone */}
      <div className="flex items-center h-14 px-4 border-b border-white/10">
        {!collapsed && (
          <span className="text-white font-semibold tracking-tight">
            Acme HQ
          </span>
        )}
        <button
          onClick={() => setCollapsed((c) => !c)}
          className="ml-auto p-1.5 rounded-md text-white/40 hover:text-white hover:bg-white/10"
          aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
        >
          {/* swap icon based on state */}
          <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
            <path d="M6 3l5 5-5 5" />
          </svg>
        </button>
      </div>

      {/* Nav items */}
      <nav className="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
        {items.map((item) => {
          const isActive = activeHref === item.href;
          const Icon = item.icon;
          return (
            <a
              key={item.href}
              href={item.href}
              className={[
                'flex items-center gap-3 px-2.5 py-2 rounded-lg text-sm font-medium',
                'transition-colors duration-100',
                isActive
                  ? 'bg-white/10 text-white'
                  : 'text-white/50 hover:text-white hover:bg-white/[0.06]',
              ].join(' ')}
            >
              <Icon size={18} strokeWidth={isActive ? 2 : 1.5} />
              {!collapsed && <span>{item.label}</span>}
              {!collapsed && item.badge != null && (
                <span className="ml-auto text-xs bg-indigo-500 text-white rounded-full px-1.5 py-0.5">
                  {item.badge}
                </span>
              )}
            </a>
          );
        })}
      </nav>
    </aside>
  );
}

A few decisions worth explaining. w-60 is 240px — the standard collapsed width for SaaS tools like Linear and Vercel's dashboard. The border-white/10 trick gives you a subtle divider without importing a color token. And strokeWidth varies between active and inactive items: it's a small detail but it makes the active icon feel 'heavier' without adding a highlight color.

Tailwind v4 Specifics for Dark Sidebar Tokens

Tailwind v4.0.2 moves theming to CSS custom properties in @theme. That changes how you should define your sidebar palette. Instead of extending tailwind.config.js with custom colors, you declare them in your CSS entry point and Tailwind generates utilities automatically.

@import 'tailwindcss';

@theme {
  --color-sidebar-bg: #0f1117;
  --color-sidebar-border: rgba(255, 255, 255, 0.08);
  --color-sidebar-active: rgba(255, 255, 255, 0.10);
  --color-sidebar-hover: rgba(255, 255, 255, 0.06);
  --color-sidebar-text: rgba(255, 255, 255, 0.50);
  --color-sidebar-text-active: #ffffff;
}

Once those tokens exist, you can write bg-sidebar-bg, text-sidebar-text, border-sidebar-border directly in JSX. This is a big improvement over v3's extend.colors approach — the tokens are real CSS variables at runtime, so they respond to prefers-color-scheme overrides without any JavaScript. Worth reading about if you're also comparing Tailwind vs CSS Modules for larger projects.

Active State and Route Matching Without a Router Dependency

The activeHref === item.href comparison in the code above is deliberately naive. In a Next.js app you'd use usePathname() from next/navigation. In React Router you'd reach for useMatch. But the component shouldn't import either — that couples it to a specific router.

Pass activeHref as a prop. Let the parent resolve it from whatever routing hook it uses. This keeps the sidebar component portable. You can drop it into a Next.js 15 project, a Vite + React Router 7 app, or even a plain HTML prototype with a static string.

What about nested routes? If /settings is active and you have sub-items like /settings/billing, you'll want prefix matching: activeHref.startsWith(item.href). Just be careful with the root path — '/' would match everything. Guard it with item.href === '/' ? activeHref === '/' : activeHref.startsWith(item.href).

Icon-Only Collapse Mode and Tooltip Accessibility

When the sidebar collapses to 64px, labels disappear. That's fine visually — but what about keyboard users and screen readers? Hidden labels need to stay accessible. The pattern is aria-label on the anchor plus a CSS tooltip that appears on hover.

Don't reach for a tooltip library for this. A title attribute works in a pinch during development but looks inconsistent across browsers. A better approach: use a data-tooltip attribute and a single CSS rule with :has() or a sibling span. Here's a self-contained version that uses only group and Tailwind's group-hover utilities:

<a
  href={item.href}
  aria-label={item.label}
  className="group relative flex items-center justify-center w-10 h-10 rounded-lg ..."
>
  <Icon size={18} />
  {collapsed && (
    <span
      role="tooltip"
      className={
        'absolute left-full ml-3 px-2 py-1 rounded-md text-xs text-white '
        + 'bg-gray-800 whitespace-nowrap pointer-events-none '
        + 'opacity-0 group-hover:opacity-100 transition-opacity z-50'
      }
    >
      {item.label}
    </span>
  )}
</a>

The ml-3 gives an 12px gap between the sidebar edge and the tooltip — enough breathing room. pointer-events-none prevents the tooltip itself from stealing mouse events, which would cause it to flicker.

Styling Variations: From Flat Dark to Glassmorphism

The flat dark style (#0f1117 background, no blur, white/10 borders) is the safest choice for productivity tools. But some SaaS products want a more visual sidebar — maybe a frosted glass look over a gradient background. Worth knowing what trade-offs come with each approach.

Glassmorphism sidebars use backdrop-filter: blur(12px) and background: rgba(255,255,255,0.05). They look great on marketing pages and dashboards with rich background visuals. The downside is performance — backdrop-filter triggers GPU compositing and can cause jank on complex pages. If you want to explore that aesthetic further, what is glassmorphism breaks down the full technique, and glassmorphism vs neumorphism covers when each style actually fits the context.

Neobrutalism is the third option — high contrast borders (usually 2px solid white), flat fills, no shadows. It reads well in developer tools and internal dashboards where the audience doesn't mind a raw aesthetic. Check out what is neobrutalism if that direction interests you. The sidebar component structure stays identical across all three; only the CSS tokens change.

Performance and Bundle Size Considerations

A sidebar is rendered on every single page. It's one of the most performance-sensitive components in an admin app. Keep it free of heavy dependencies. No date libraries, no animation libraries, no complex state management. useState for collapse, usePathname (or a prop) for active state — that's it.

Transitions should use transition-all duration-200 at most. More complex animations add layout thrashing on every collapse toggle. The width transition (w-16 to w-60) already triggers a reflow — adding opacity, transform, and color changes simultaneously is asking for trouble on low-end hardware.

Should you pair your sidebar with a theme toggle in React? Yes, but keep the toggle logic outside the sidebar component. The sidebar should receive a colorScheme prop or read from a CSS class on <html>. It shouldn't own the toggle logic itself. That separation means you can test the sidebar in isolation without simulating theme switches.

FAQ

What width should a dark SaaS sidebar be in collapsed vs expanded state?

Expanded: 240px (Tailwind's w-60) is the most common choice, used by Linear, Vercel, and Notion. Collapsed icon-only mode: 64px (w-16). These widths are conventions, not rules — but deviating meaningfully will feel off to users who switch between tools.

How do I handle sidebar state in Next.js App Router without prop drilling?

Create a client-side context provider that wraps your layout. Export a useSidebar() hook that returns { collapsed, setCollapsed }. Wrap your root layout with it. The sidebar and any toggle button elsewhere on the page can both consume the hook without props passing through every layer.

Does backdrop-filter: blur hurt sidebar performance?

Yes, noticeably on some devices. The blur is GPU-composited and the sidebar repaints on every scroll if the content behind it moves. For productivity tools with dense tables and lists, stick with a solid dark background. Reserve backdrop-filter for marketing-facing dashboards where visual flair matters more than raw scroll performance.

How do I add a notification badge to sidebar nav items?

Add a badge?: number field to your nav item type. Render it as an absolutely positioned span over the icon in collapsed mode, or as an inline chip next to the label in expanded mode. Use bg-indigo-500 or bg-red-500 depending on whether it's informational or an error count. Hide the badge when its value is 0.

Can I animate the sidebar collapse with Framer Motion?

You can, but it's usually overkill. Tailwind's built-in transition-all duration-200 ease-in-out on the width produces a clean collapse with zero extra bundle size. If you need more control — spring physics, drag-to-resize — then Framer Motion makes sense. Otherwise, the CSS transition is fine.

How do I persist the collapsed state between page navigations?

Store it in localStorage and read it on mount inside a useEffect. Alternatively use a cookie so SSR can read it and avoid a layout shift on first render. In Next.js, you can set a cookie server-side and read it in your root layout to set the initial class before hydration.

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

Read next

Dark UI Patterns for SaaS: Navigation, Sidebars, Data TablesGlassmorphism Stats Widget: KPI Cards for Admin DashboardsPricing Card Variants: 7 SaaS Designs That Increase ConversionsTailwind Pricing Page: SaaS Tier Comparison Design