@bensdev/react-sidebar
v0.3.0
Published
Themeable, router-agnostic, zero-dependency React sidebar with collapse, submenus, and a mobile drawer.
Maintainers
Readme
@bensdev/react-sidebar
A themeable, router-agnostic, zero-dependency React sidebar: collapsible rail, nested groups, a mobile drawer, and full CSS-custom-property theming. No Tailwind required, no router required, no icon library required — bring your own of each, or none at all.
- Zero runtime dependencies.
react/react-domare the only peer dependencies. - Router-agnostic. Works with a plain
<a>out of the box; plug in React Router'sLink, Next.js'sLink, or anything else vialinkComponent/renderLink. - Themeable via CSS variables. Every color, size, radius, and transition is a
--bsb-*custom property. Override with thethemeprop, plain CSS, or per-slotclassNames. - Deeply configurable. Links, actions, nested groups, headings, dividers, badges, disabled/ hidden items, and fully custom rows — all data-driven, no forking required.
- Accessible. Keyboard navigation, focus trap + Escape in the drawer,
aria-current,aria-expanded, visible focus rings,prefers-reduced-motionsupport. - SSR-safe. No
window/documentaccess during render; ships a"use client"banner so it drops straight into a Next.js App Router Server Component tree.
Install
npm install @bensdev/react-sidebarimport { Sidebar } from '@bensdev/react-sidebar';
import '@bensdev/react-sidebar/styles.css';Import the CSS once, near your app's own global stylesheet (before it, if you want your app's CSS to win any conflicts).
Layout requirements
.bsb-root is height: 100%, and its internal .bsb-nav is the only element that scrolls
(overflow-y: auto). Both only work if every ancestor between <Sidebar> and a definite-height
container also resolves to a real height — percentage heights fall back to auto otherwise. Miss
this and two things break together: the rail grows to fit its content instead of scrolling
internally, and the page picks up a scrollbar it shouldn't have — most visible as a height jump
whenever a group opens/closes.
Give your root layout a definite height and let <Sidebar> sit as a flex/grid item next to your
content:
html, body, #root { height: 100%; }<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Sidebar items={items} />
<main style={{ flex: 1, overflow: 'auto' }}>{children}</main>
</div>A min-height (or no height at all) on that wrapper isn't enough — min-height doesn't give
descendants a definite height to resolve height: 100% against, so .bsb-nav still won't scroll.
CSS import order. "Near your app's own global stylesheet" above assumes your bundler honors
source order. Frameworks that bundle by module graph — Next.js App Router in particular — always
place a component's own imported CSS after the root layout's global CSS, regardless of which
file imports which. If your global CSS and @bensdev/react-sidebar/styles.css both set the same
property on the same selector at equal specificity, the sidebar's rule wins there no matter what
your source order says — override with a more specific selector (.my-app .bsb-root) or
!important instead of relying on import order.
Quick start
import { Sidebar, greenMist, type SidebarItem } from '@bensdev/react-sidebar';
import '@bensdev/react-sidebar/styles.css';
import { Home, Store, Settings, LogOut } from 'lucide-react';
const items: SidebarItem[] = [
{ label: 'Home', href: '/home', icon: <Home size={18} /> },
{ label: 'Suppliers', href: '/suppliers', icon: <Store size={18} />, badge: 12 },
{ label: 'Settings', href: '/settings', icon: <Settings size={18} /> },
];
export function AppSidebar() {
return (
<Sidebar
items={items}
theme={greenMist}
brand={{ logo: 'B', title: 'My App', subtitle: 'Dashboard' }}
user={{ name: 'Ada Lovelace', email: '[email protected]' }}
footerAction={{ icon: <LogOut size={15} />, label: 'Logout', onClick: () => {} }}
/>
);
}With no other props, active-route detection falls back to window.location.pathname and links
render as plain <a href> — this works in any React app, router or not. For an SPA router, read
on.
Routing recipes
The package computes active state itself, so it behaves identically regardless of router. Pass
currentPath explicitly whenever you have a client-side router — the window.location fallback
only reacts to popstate/hashchange, not to pushState-based navigation.
React Router (v6/v7)
import { Link, useLocation } from 'react-router-dom';
<Sidebar items={items} linkComponent={Link} hrefProp="to" currentPath={useLocation().pathname} />Next.js App Router
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
<Sidebar items={items} linkComponent={Link} currentPath={usePathname()} />TanStack Router
import { Link, useRouterState } from '@tanstack/react-router';
<Sidebar
items={items}
linkComponent={Link}
currentPath={useRouterState({ select: (s) => s.location.pathname })}
/>Anything else — analytics-wrapped links, a design-system <Link> with a different prop
shape, or navigation that isn't a real <a> at all:
<Sidebar
items={items}
renderLink={({ href, isActive, className, children, props }) => (
<MyLink to={href} data-active={isActive} className={className} {...props}>
{children}
</MyLink>
)}
/>Items
type SidebarItem =
| { href: string; label: ReactNode; icon?: ReactNode; badge?: ReactNode; end?: boolean, ... } // link (default)
| { type: 'action'; label: ReactNode; onSelect: (e) => void, ... } // button, no navigation
| { type: 'group'; label: ReactNode; items: SidebarItem[]; collapsible?: boolean, ... } // nested submenu
| { type: 'heading'; label: ReactNode } // section label
| { type: 'divider' } // horizontal rule
| { type: 'custom'; render: (ctx) => ReactNode } // anything at allEvery item accepts hidden (the standard way to do role-based gating — filter is applied at
render time) and disabled. Links accept end for exact-match active state (mirrors React
Router's NavLink end), and isActive to override the computed value entirely.
{ type: 'group', label: 'Admin', items: [
{ label: 'Dashboard', href: '/admin', end: true, icon: <LayoutDashboard size={18} /> },
{ label: 'Users', href: '/admin/users', icon: <Users size={18} />, badge: { content: 3, tone: 'danger' } },
{ label: 'Billing', href: '/admin/billing', hidden: !user.isAdmin },
]}A group with collapsible: false renders as a static section: its label becomes a heading and
children are always visible — a lighter-weight alternative to a top-level heading + flat items.
That holds on the collapsed icon rail too: collapsible: false overrides collapsedGroupBehavior
entirely, so its children never fold into a flyout, and never disappear. Give the group its own
icon if you want a row for it on the rail as well; without one, only the children (each with
their own icon) render there, the same as a heading would.
Collapsible groups (the default) present differently once the sidebar collapses to its icon
rail: a hover/focus flyout by default (collapsedGroupBehavior="flyout"), or set it to
"expand" (rendered inline and indented, same as expanded) or "ignore" (trigger only, children
unreachable while collapsed) instead.
Collapse / expand
// Uncontrolled, persisted to localStorage under a key you choose:
<Sidebar items={items} persistCollapse="my-app-sidebar-collapsed" />
// Fully controlled:
<Sidebar items={items} collapsed={collapsed} onCollapsedChange={setCollapsed} />
// Non-collapsible:
<Sidebar items={items} collapsible={false} />persistCollapse also accepts a StorageAdapter ({ getItem, setItem, subscribe? }) if you
want to back it with something other than localStorage.
Mobile drawer
Below breakpoint (default 1024px, or pass any media-query string), the desktop rail is
replaced by a slide-in drawer. Drive it from your own hamburger button:
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Menu</button>
<Sidebar items={items} mobileOpen={open} onMobileClose={() => setOpen(false)} />The drawer includes a focus trap, Escape-to-close, backdrop-click-to-close, body scroll lock, and
respects prefers-reduced-motion — all on by default and individually toggleable
(trapFocus, closeOnEscape, closeOnBackdropClick, lockScroll, reduceMotion).
It's portaled to document.body by default (pass portalTarget for somewhere else, or
disablePortal to render it in place), so it escapes any overflow: hidden/z-index ancestor
your layout has. The sidebar's theme tokens, resets, and dark-mode detection travel with it across
the portal boundary — no .bsb-root wrapper is left behind at the drawer's original position, and
none of its own CSS is scoped to depend on being a descendant of one.
Theming
Every visual value is a --bsb-* CSS custom property (see src/styles.css for the full list —
layout, radii, typography, motion, and color tokens). Three ways to change them, all without
touching package source:
1. The theme prop (inline styles, wins over everything):
<Sidebar items={items} theme={{ bg: '#0f172a', itemActiveBg: '#1d4ed8', radiusItem: '12px' }} />
// Split values per color scheme:
<Sidebar items={items} colorScheme="auto" theme={{ light: { bg: '#fff' }, dark: { bg: '#0f172a' } }} />2. Plain CSS:
.my-app .bsb-root {
--bsb-bg: #0f3a18;
--bsb-item-active-bg: rgba(31, 110, 46, 0.6);
}3. Per-slot class names (for Tailwind users who'd rather use utilities than tokens):
<Sidebar items={items} classNames={{ item: 'my-tailwind-classes', itemActive: 'bg-primary-600' }} />Color scheme
colorScheme is "light" | "dark" | "auto" (default "auto"). In "auto" mode the sidebar
looks for a .dark, [data-theme="dark"], or [data-color-scheme="dark"] ancestor (or the
matching light variant, or prefers-color-scheme as a last resort) and re-resolves whenever
that ancestor's attributes change — no extra JS wiring needed if your app already toggles a class
on <html>.
Presets
import { greenMist, midnight, slate } from '@bensdev/react-sidebar';
<Sidebar items={items} theme={greenMist} />slate— the stylesheet's own defaults (useful for explicitness).greenMist— the original B-Lines palette, including itslight/darksplit.midnight— a neutral dark theme.
Build your own with defineTheme({...}) for autocomplete, or just pass a plain object.
Header / footer
<Sidebar
items={items}
brand={{ logo: <Logo />, title: 'Acme', subtitle: 'Admin', badge: 'Beta' }}
user={{ name: user.name, email: user.email, avatarUrl: user.avatarUrl }}
footerAction={{ icon: <LogOut size={15} />, label: 'Logout', onClick: logout }}
collapsedFooter="stack" // keep the logout button reachable even when collapsed
/>For full control, header and footer accept any ReactNode or a (ctx) => ReactNode
render function, replacing the built-in brand/user blocks entirely.
Escape hatches
classNames— override any of ~30 named slots (root,item,itemActive,badge,groupPanel,toggle,drawer, …) without forking styles.slots— swap the collapse chevron, group chevron, close icon, or tooltip renderer.renderItem(item, ctx, defaultNode)— intercept any single row and return your own markup (returnundefinedto fall through to the default).- Item-level
renderon{ type: 'custom' }items for one-off rows (dividers with a label, a search box, an upgrade banner, anything).
Accessibility
<nav aria-label> landmark, aria-current="page" on the active link, aria-expanded/
aria-controls on group triggers, visible :focus-visible rings, a labeled and focus-trapped
drawer dialog (role="dialog" aria-modal) with Escape support, and full prefers-reduced-motion
support (respected automatically, or forced with reduceMotion).
SSR / Next.js
The package never touches window/document during render — only inside effects and
useSyncExternalStore. The compiled bundle starts with "use client", so it can be imported
directly from a Server Component file in the App Router. Persisted collapse state hydrates safely
(server and first client paint both render defaultCollapsed; the real value swaps in right
after).
API reference
The full prop surface is exported as SidebarProps, along with every item/theme/slot type
(SidebarItem, SidebarLinkItem, SidebarGroupItem, SidebarTheme, SidebarClassNames, …) —
import them for autocomplete:
import type { SidebarProps, SidebarItem, SidebarTheme } from '@bensdev/react-sidebar';License
MIT
