A React Sidebar in 60 Lines and Zero JavaScript
Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.
A React sidebar does not need a library, a useState, or a client component. This storefront's docs navigation is 60 lines across two files, ships zero JavaScript, and stays sticky with one CSS class. It also contains a bug that only appears when you add a second page — the kind every hand-rolled sidebar tutorial leaves in.
The whole thing, both files
DocsSidebar is 42 lines and DocsSidebarLink is 18. Neither carries "use client", so both render on the server and contribute nothing to the bundle:
// src/components/organisms/DocsSidebar.tsx
export default function DocsSidebar() {
return (
<aside className="shrink-0 lg:sticky lg:top-24 lg:h-[calc(100vh-7rem)] lg:w-64 lg:overflow-y-auto lg:pr-2">
<nav aria-label="Documentation" className="flex flex-col gap-1">
{docsNav.map((section) =>
section.children ? (
<div key={section.label} className="mt-4 first:mt-0">
<p className="px-3 py-2 text-sm font-semibold text-title-color">
{section.label}
</p>
<div className="flex flex-col gap-0.5 border-l border-stroke pl-2">
{section.children.map((child) => (
<DocsSidebarLink
key={`${section.label}-${child.label}`}
label={child.label}
href={child.href}
active={child.active}
/>
))}
</div>
</div>
) : (
<DocsSidebarLink key={section.label} {...section} />
),
)}
</nav>
</aside>
);
}
// src/components/molecules/DocsSidebarLink.tsx
export default function DocsSidebarLink({ label, href, active }: DocLink) {
return (
<a
href={href}
aria-current={active ? "page" : undefined}
className={`block rounded-lg px-3 py-2 text-sm transition-colors ${
active
? "bg-primary-25 font-medium text-primary-600"
: "text-text-color-secondary hover:bg-gray-50 hover:text-text-color"
}`}
>
{label}
</a>
);
}
Three decisions in there are worth more than the markup.
Sticky is CSS, not a scroll listener. lg:sticky lg:top-24 lg:h-[calc(100vh-7rem)] lg:overflow-y-auto gives a sidebar that pins below the fixed header, gets its own scrollbar when the nav outgrows the viewport, and never runs a scroll handler. Every "React sticky sidebar" tutorial that reaches for useEffect and window.scrollY is solving a problem position: sticky solved in 2017.
The landmark is labelled. <nav aria-label="Documentation"> inside <aside> matters because this page has two navigation landmarks — the site header is the other one. Unlabelled, a screen-reader user hears "navigation" twice and has to enter each to tell them apart. One attribute removes that.
aria-current="page" carries the state, not the colour. The active row is tinted and announced. A sidebar that signals the current page only with bg-primary-25 communicates nothing to anyone not looking at it, and that is the most common accessibility defect in sidebar implementations.
Rendered inside a two-column template that collapses to one:
// src/components/templates/DocsTemplate.tsx
<div className="flex flex-col gap-8 py-10 lg:flex-row lg:gap-12 lg:py-12">
<DocsSidebar />
<DocsContent />
</div>
The bug: active state is data, not route
active arrives as a prop, and it comes from a hand-written field in the data file:
// src/data/docs.tsx
export const docsNav: DocNavSection[] = [
{ label: "Introduction", href: "/docs", active: true },
{
label: "Resources",
href: "/docs",
children: [
{ label: "Pricing & FAQ", href: "/pricing#faq" },
{ label: "License", href: "/license" },
{ label: "Changelog", href: "/changelog" },
{ label: "Support", href: "/contact" },
],
},
];
active: true is a static literal. It is not derived from the current URL, which means the sidebar highlights "Introduction" regardless of which page renders it. That is correct today for exactly one reason: /docs is a single page, so the only route the sidebar ever renders on is the one it hard-codes.
Add a second doc page and the sidebar highlights the wrong row on it, silently, with no error and nothing for a test to fail on. The data file's own header comment anticipates the growth — "When per-framework doc sub-pages ship, grow this nav with them" — without anticipating that active will not grow with it.
There are two honest fixes, and the choice between them is the actual architectural decision a sidebar forces:
// Fix A — client component, derives from the live pathname
"use client";
import { usePathname } from "next/navigation";
export default function DocsSidebarLink({ label, href }: DocLink) {
const pathname = usePathname();
const active = pathname === href;
return <a href={href} aria-current={active ? "page" : undefined} /* … */>{label}</a>;
}
// Fix B — stays a Server Component; the page passes down what it knows
<DocsSidebar currentPath="/docs/installation" />
// …and the link compares: active={href === currentPath}
Fix A is the one every tutorial shows, and it converts the leaf of your navigation tree into a client component — which, because usePathname needs the client boundary, drags the rendering of every link into the browser. Fix B keeps the whole sidebar on the server and threads one string down from the route segment that already knows it. For a static docs site, B is strictly better: same behaviour, no hydration, no bundle. A earns its place when the highlight must respond to client-side navigation that the server render cannot see — which, in the App Router, it usually can.
The second thing: a raw <a> in a Next.js app
DocsSidebarLink renders <a href={href}> rather than next/link. Eighteen of this codebase's 91 components import next/link; this one does not, so every sidebar click is a full document navigation — a fresh HTML request, a fresh paint, and the loss of any client state on the page.
For a docs sidebar whose links go to /pricing, /license and /contact — pages in different route trees with nothing to preserve — that is a defensible trade rather than a bug, and it matches the plain-<img> convention this codebase uses elsewhere. What is worth knowing is that the lint rule that would normally catch it cannot see it. @next/next/no-html-link-for-pages, which ships enabled in eslint-config-next's core-web-vitals preset, matches literal internal hrefs on raw anchors. Here the href is a variable, so the rule finds nothing and the lint run stays clean at zero errors. Any <a href={someVariable}> in a Next.js app is invisible to it.
What a collapsible sidebar actually costs
The docs sidebar has no mobile drawer. Below lg the template's flex-col stacks it above the article as a plain list — six links, so a reader scrolls past them in about a second. That is the right answer for six links and the wrong answer for sixty, which is why the site header, with a full nav plus account controls, pays for the other thing:
// src/components/organisms/Header.tsx — "use client"
const [navOpen, setNavOpen] = useState(false);
// …
<button aria-label={navOpen ? "Close menu" : "Open menu"} aria-expanded={navOpen}
onClick={() => setNavOpen((o) => !o)} type="button">
useState, an aria-expanded toggle, an Escape-key listener, a click-outside overlay, and closing on link click. That is the real price list, and it is why 24 of this codebase's 91 components are client components while the other 67 are not.
| Sidebar variant | Client JS | State to manage | Use when |
|---|---|---|---|
| Static nav, no active state | none | none | footer-style link column |
| Active row from a server prop (Fix B) | none | none | docs, settings, any routed tree |
Active row from usePathname (Fix A) | the link leaf | none — the hook owns it | highlight must survive client nav |
| Collapsible groups | the tree | open/closed per group | 30+ links with real hierarchy |
| Mobile drawer | the shell | open, focus trap, Escape, overlay | the nav is too tall to stack |
Read that top to bottom before you install anything. Most "React sidebar" packages sell you row five, and most projects need row two.
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
Hard-coding active in the nav data | Correct until page two, then silently wrong | Derive from the route — a server prop or usePathname |
useEffect + scrollY for stickiness | Jank, a listener per mount, layout thrash | position: sticky and a top offset |
| Colour-only active state | Screen readers cannot tell which page you are on | Add aria-current="page" |
Two unlabelled <nav> landmarks | "navigation, navigation" in the landmark list | aria-label on each |
"use client" on the sidebar shell | The whole tree hydrates to highlight one row | Push the boundary down to the leaf, or pass a prop |
<a href={variable}> for internal routes | No prefetch, no client nav, and the lint rule stays silent | Use next/link, or decide the trade knowingly |
| No max height on a sticky sidebar | Long navs scroll off and become unreachable | h-[calc(100vh-…)] overflow-y-auto |
Frequently asked questions
Do I need a library like react-pro-sidebar for this?
Not for a routed navigation tree. The version above is 60 lines and covers grouping, active state, sticky positioning and landmark labelling. A library earns its place at the bottom two rows of that table — collapsible groups with animation, a focus-trapped mobile drawer, icon-rail collapse — where the state coordination is genuinely fiddly. We measure that hand-rolled-versus-library trade across this codebase's 91 components in when hand-rolling actually wins.
How do I highlight the active link in a Next.js App Router sidebar?
Compare the current path to each link's href — pathname === href for exact matches, pathname.startsWith(href) for section highlighting where a child route should light up its parent. Get the path from usePathname() in a client component, or pass it down from the server component that already has the route params. Do not store it in your nav data.
Can a sidebar be a Server Component?
Yes, and it should be by default. Navigation is a list of links rendered from data — nothing about it requires the browser. The moment it needs interaction (a collapsible group, a drawer), push "use client" down to the smallest component that needs it rather than putting it on the shell, or the whole tree ships to the browser to make one chevron rotate.
Why does my sticky sidebar not stick?
Almost always an ancestor with overflow: hidden, overflow: auto or a transform, any of which breaks position: sticky for descendants. The second most common cause is no top value — sticky without an offset behaves like relative. Check the ancestor chain before adding JavaScript.
Should the sidebar collapse on mobile? Only if it is tall enough to be in the way. Six links stacked above the article cost a reader one flick; sixty do not. Measure the rendered height at 375px before you pay for a drawer, a focus trap and an Escape handler.
Templates where the sidebar is the product
ASoc Estate, ASoc Lura — 11 dashboards across roughly 177 routed pages, with a collapsible sidebar, header search and notifications — and ASoc Scholar, at 13 dashboards and 210+ pages, are the case where every row of that cost table is already paid for and tested.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the accessible-disclosure pattern the mobile version of this navigation uses, read the accessible mega menu; for where the client boundary should sit, Server Components versus Client Components.
