DEV Community

Cover image for How to Build a Sticky Sidebar Navigation with Next.js and Tailwind CSS v4
TheKitBase
TheKitBase

Posted on • Originally published at thekitbase.app

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

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 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 (
    <>
      <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>

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

      <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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tailwind CSS v4 tokens for dark mode

/* 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;
}

@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);
}
Enter fullscreen mode Exit fullscreen mode

Common mistakes to avoid

  • Using position:fixed instead of sticky - fixed sidebars require manual padding on the main content
  • Active link detection with pathname.includes() - use exact match or startsWith carefully
  • Forgetting overflow-y-auto on the nav - long lists will overflow without it
  • Not closing the mobile sidebar on navigation - add onClick={() => setOpen(false)} to each link
  • Using window.innerWidth for mobile detection - use Tailwind responsive prefixes instead

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 parent must be display: flex and taller than the viewport.

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 "use client".


Originally published at thekitbase.app

Top comments (0)