What we are building
On desktop the sidebar is 256px wide and collapses to a 72px rail. The width animates and the main area reflows next to it, so cards and tables get the extra room. In the rail, each icon shows a tooltip on hover or keyboard focus. On phones there is a top bar with a menu button, and the sidebar slides over the page with a dimmed backdrop.
Three files: a server layout that reads the saved choice, a client Shell with the state and the shortcut, and NavLinks that both the rail and the phone drawer use. If you already use shadcn/ui, its Sidebar does this too, and the post linked under Read next shows the same cookie trick there.
The nav links
The label fades and shrinks to zero width instead of being removed, so screen readers still read each link's name in the rail. The tooltip is aria-hidden because the name is already there, and it only exists while collapsed.
import Link from "next/link";
const items = [
{ href: "/dashboard", label: "Dashboard", icon: "M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-18v6h8V3z" },
{ href: "/projects", label: "Projects", icon: "M3 7h18v13H3zM8 7V4h8v3" },
{ href: "/settings", label: "Settings", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM4 12h2m12 0h2M12 4v2m0 12v2" },
];
export function NavLinks({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
return (
<nav aria-label="Main">
<ul className="space-y-1">
{items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
className="group relative flex h-12 items-center gap-3 rounded-xl px-3.5 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
>
<svg viewBox="0 0 24 24" className="size-5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d={item.icon} />
</svg>
{/* The label stays in the accessibility tree, it only fades and shrinks */}
<span
className={`overflow-hidden whitespace-nowrap transition-[opacity,width] duration-200 ${
collapsed ? "w-0 opacity-0" : "w-auto opacity-100"
}`}
>
{item.label}
</span>
{collapsed && (
<span
aria-hidden="true"
className="pointer-events-none absolute left-full ml-5 whitespace-nowrap rounded-md bg-zinc-800 px-2 py-1 text-xs opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
{item.label}
</span>
)}
</Link>
</li>
))}
</ul>
</nav>
);
}Swap the paths for your own icons. The tooltip sits at left-full, outside the rail, so do not put overflow-hidden on the sidebar or it gets clipped.
The Shell: state, Ctrl+B and the phone drawer
The width transition is transition-[width] between w-64 and w-[72px]. Because the sidebar is a flex item and main is flex-1 min-w-0, the content reflows on every frame with no extra code. motion-reduce:transition-none makes it a jump for people who turned on reduced motion.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { NavLinks } from "./NavLinks";
export function Shell({ defaultCollapsed, children }: { defaultCollapsed: boolean; children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [mobileOpen, setMobileOpen] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const toggle = useCallback(() => setCollapsed((c) => !c), []);
// Remember the choice for the next visit (the layout reads this cookie).
useEffect(() => {
document.cookie = `sidebar=${collapsed ? "collapsed" : "open"}; path=/; max-age=31536000; samesite=lax`;
}, [collapsed]);
// Ctrl+B / Cmd+B toggles, except while typing (it means bold in editors).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== "b" || !(e.metaKey || e.ctrlKey)) return;
const el = e.target as HTMLElement;
if (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return;
e.preventDefault();
toggle();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggle]);
// Phone drawer: Escape closes it, and focus moves into it when it opens.
useEffect(() => {
if (!mobileOpen) return;
closeRef.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMobileOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [mobileOpen]);
return (
<div className="min-h-dvh bg-zinc-950 text-zinc-100 md:flex">
{/* Desktop: a rail that animates its width; the main area reflows next to it */}
<aside
className={`sticky top-0 hidden h-dvh shrink-0 flex-col border-r border-zinc-800 p-3 transition-[width] duration-300 ease-out motion-reduce:transition-none md:flex ${
collapsed ? "w-[72px]" : "w-64"
}`}
>
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title="Toggle sidebar (Ctrl+B)"
className="mb-4 grid size-12 place-items-center rounded-xl text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<svg viewBox="0 0 24 24" className={`size-5 transition-transform duration-300 ${collapsed ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="m15 6-6 6 6 6" />
</svg>
</button>
<NavLinks collapsed={collapsed} />
</aside>
{/* Phone: a top bar with a menu button and a drawer over the page */}
<div className="sticky top-0 z-30 flex items-center gap-3 border-b border-zinc-800 bg-zinc-950/90 px-4 py-3 backdrop-blur md:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
className="grid size-10 place-items-center rounded-lg border border-zinc-700"
>
<span className="sr-only">Open menu</span>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="font-semibold">Acme</span>
</div>
<div
onClick={() => setMobileOpen(false)}
aria-hidden="true"
className={`fixed inset-0 z-40 bg-black/60 transition-opacity md:hidden ${mobileOpen ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
<aside
id="mobile-nav"
inert={!mobileOpen}
aria-label="Menu"
className={`fixed inset-y-0 left-0 z-50 w-72 border-r border-zinc-800 bg-zinc-950 p-3 transition-transform duration-300 ease-out md:hidden ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
ref={closeRef}
type="button"
onClick={() => setMobileOpen(false)}
className="mb-4 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-zinc-800"
>
Close
</button>
<NavLinks collapsed={false} onNavigate={() => setMobileOpen(false)} />
</aside>
<main className="min-w-0 flex-1 p-6">{children}</main>
</div>
);
}Ctrl+B means bold in text editors, so the shortcut skips inputs, textareas and contenteditable. The phone drawer uses inert while closed, so its links are not in the tab order, and focus moves to Close when it opens. Escape, the backdrop and any link close it.
Remember it without a flash
If the collapsed state lived in localStorage, the first paint would always be the open sidebar and it would snap shut after hydration. A cookie fixes that: the Shell writes it, and the layout reads it on the server, so the HTML already has the right width.
import { cookies } from "next/headers";
import { Shell } from "./Shell";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Read the saved choice on the server, so the first paint is already right.
const collapsed = (await cookies()).get("sidebar")?.value === "collapsed";
return <Shell defaultCollapsed={collapsed}>{children}</Shell>;
}Reading cookies makes the routes under this layout render on each request. For an app area behind a login that is usually the case anyway. Put the layout in a route group like (app) so your marketing pages stay static.
What we checked
In Chrome, in a Next.js 15.5 app with React 19 and Tailwind v4. At 1440px: the sidebar went from 256px to 72px on Ctrl+B and the content grid widened from 1136px to 1320px. Ctrl+B inside a text field did nothing. Hovering an icon in the rail showed its tooltip, and the link's accessible name stayed "Projects". After a reload the server sent the 72px rail straight away, and the edge button opened it to 256px again. At 390px: the rail was hidden, the menu button opened the drawer and focused Close, and Escape, a tap on the backdrop and a tap on a link each closed it.
Using Cursor, v0 or Lovable
Cursor: paste the three files and ask it to move your existing nav items into the items array. Tell it to keep the cookie, or it may switch to localStorage and bring the flash back.
v0: it often reaches for the shadcn Sidebar. That is fine; if you want this one instead, paste Shell.tsx and ask v0 to keep the Tailwind classes and the keyboard handler.
Lovable: projects are React with Vite, so there is no server layout. Read the cookie or localStorage in your entry file before the first render and pass it as defaultCollapsed; Shell and NavLinks work as they are once you swap next/link for your router's link.
Or use the ready-made piece
If you want the finished dashboard version, Reflow Sidebar collapses into a rail on a spring with a small overshoot, and the whole app moves with it: the KPI cards widen, the chart stretches and the table opens a column it had no room for. The current page pill stretches between pages, labels come back one by one, and it has the same Ctrl/Cmd + B, tooltips and phone drawer.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- How do I make a collapsible sidebar in Tailwind?
- Keep a collapsed flag in React state and switch the sidebar between two widths, for example w-64 and w-[72px], with transition-[width]. Make the main area flex-1 min-w-0 so it reflows.
- How do I keep the sidebar collapsed after a reload in Next.js?
- Write the choice to a cookie when it changes and read it with cookies() in the server layout. The first HTML then has the right width, so nothing flashes.
- How do I add a Ctrl+B shortcut to toggle the sidebar?
- Listen for keydown on window, check e.key is b with ctrlKey or metaKey, skip inputs, textareas and contenteditable, then call preventDefault and toggle.
- How should a collapsible sidebar work on mobile?
- Hide the rail below the md breakpoint and show a menu button that opens the sidebar as a drawer over the page, with a backdrop, Escape to close and inert while closed.