Launch pricing - early-adopter rates, prices increase June 9th. Shop now →

Tutorials6 min readby Minnie

How to Build a Sticky Sidebar Navigation with Next.js and Tailwind CSS v4

A complete guide to building a sticky sidebar navigation in Next.js with Tailwind CSS v4 - collapsible on mobile, active link highlighting, keyboard accessible, and flash-free dark mode support.

A production-quality sticky sidebar in Next.js needs four things: CSS position sticky for the layout, active link detection with usePathname, a mobile collapse mechanism, and dark mode tokens. Here is the complete implementation.

Sidebar navigation is one of the most common patterns in SaaS dashboards and documentation sites - and one of the most commonly broken. A good sidebar is sticky, collapses on mobile, highlights the active route, and works with dark mode. Here's how to build it correctly with Next.js App Router and Tailwind CSS v4.

The layout structure

The key to a sticky sidebar is the parent layout. The sidebar and main content sit side-by-side in a flex container, and the sidebar uses `sticky top-0 h-screen` to stay in view while the content scrolls.

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

The sidebar component

// components/Sidebar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";

const navItems = [
  { href: "/dashboard", label: "Overview" },
  { href: "/dashboard/analytics", label: "Analytics" },
  { href: "/dashboard/users", label: "Users" },
  { href: "/dashboard/billing", label: "Billing" },
  { href: "/dashboard/settings", label: "Settings" },
];

export function Sidebar() {
  const pathname = usePathname();
  const [open, setOpen] = useState(false);

  return (
    <>
      {/* Mobile toggle */}
      <button
        className="fixed top-4 left-4 z-50 md:hidden p-2 bg-card border border-border"
        onClick={() => setOpen(!open)}
        aria-label="Toggle navigation"
      >
        <span className="block w-5 h-0.5 bg-foreground mb-1" />
        <span className="block w-5 h-0.5 bg-foreground mb-1" />
        <span className="block w-5 h-0.5 bg-foreground" />
      </button>

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

      {/* Sidebar */}
      <aside
        className={`
          fixed md:sticky top-0 left-0 z-40 h-screen w-64
          bg-card border-r border-border
          flex flex-col
          transition-transform duration-200
          md:translate-x-0
          ${open ? "translate-x-0" : "-translate-x-full"}
        `}
      >
        <div className="p-6 border-b border-border">
          <span className="font-bold text-foreground">Dashboard</span>
        </div>

        <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
          {navItems.map((item) => {
            const active = pathname === item.href;
            return (
              <Link
                key={item.href}
                href={item.href}
                onClick={() => setOpen(false)}
                className={`
                  flex items-center px-3 py-2 text-sm font-medium
                  transition-colors
                  ${active
                    ? "bg-primary/10 text-primary"
                    : "text-muted-foreground hover:bg-muted hover:text-foreground"
                  }
                `}
              >
                {item.label}
              </Link>
            );
          })}
        </nav>
      </aside>
    </>
  );
}

Tailwind CSS v4 tokens for the sidebar

Using CSS custom properties for colors means the sidebar automatically switches between light and dark mode without any extra JavaScript. Define the tokens once in globals.css and every component inherits them.

/* globals.css */
@import "tailwindcss";

@theme {
  --color-background:       #ffffff;
  --color-foreground:       #0c0c0d;
  --color-card:             #f8f8f6;
  --color-border:           #e8e8e4;
  --color-muted-foreground: #6b6b6e;
  --color-muted:            #f0f0ee;
  --color-primary:          #e5402a;
}

/* Dark mode overrides */
@variant dark (&:where(.dark, .dark *)) {
  --color-background:       #0c0c0d;
  --color-foreground:       #fcfcfa;
  --color-card:             #141416;
  --color-border:           rgba(255,255,255,0.08);
  --color-muted-foreground: #9a9a9c;
  --color-muted:            rgba(255,255,255,0.05);
}

Adding a collapsible sidebar for desktop

For dashboards where users want more screen space, a collapsible desktop sidebar is a common requirement. The pattern is to toggle between a full-width sidebar (showing labels) and an icon-only sidebar (showing just icons). Store the collapsed state in localStorage so it persists across page loads.

// Store collapse state in localStorage
const [collapsed, setCollapsed] = useState(() => {
  if (typeof window === "undefined") return false;
  return localStorage.getItem("sidebar-collapsed") === "true";
});

const toggleCollapse = () => {
  const next = !collapsed;
  setCollapsed(next);
  localStorage.setItem("sidebar-collapsed", String(next));
};

// Apply to sidebar width
<aside className={`
  sticky top-0 h-screen border-r border-border
  transition-all duration-200
  ${collapsed ? "w-16" : "w-64"}
`}>

Common mistakes to avoid

  • Using position:fixed instead of sticky - fixed sidebars require manual padding on the main content; sticky just works with the flex layout
  • Active link detection with pathname.includes() - this marks parent routes as active when children are open; use exact match or startsWith carefully
  • Forgetting overflow-y-auto on the nav - long navigation lists will overflow the sidebar without it
  • Not closing the mobile sidebar on navigation - add onClick={() => setOpen(false)} to each link
  • Using window.innerWidth for mobile detection - use Tailwind's responsive prefixes (md:) instead; they're CSS and don't cause hydration issues

Frequently asked questions

How do I make a sidebar sticky in Next.js?

Use `position: sticky` (Tailwind: `sticky top-0 h-screen`) on the sidebar inside a flex parent. The sidebar stays in view while the main content scrolls. This requires the parent to be `display: flex` and the main content to have `overflow-y-auto` or use the natural document scroll.

How do I highlight the active link in a Next.js sidebar?

Use the `usePathname` hook from `next/navigation` to get the current route. Compare it to each navigation item's href and apply active styles conditionally. Mark the component with `use client` since usePathname is a client-side hook.

TheKitBase dashboard templates ship with a fully built collapsible sidebar, active link detection, mobile drawer, and dark mode - ready to customize. From $39.

Browse Templates