npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bensdev/react-sidebar

v0.3.0

Published

Themeable, router-agnostic, zero-dependency React sidebar with collapse, submenus, and a mobile drawer.

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-dom are the only peer dependencies.
  • Router-agnostic. Works with a plain <a> out of the box; plug in React Router's Link, Next.js's Link, or anything else via linkComponent / renderLink.
  • Themeable via CSS variables. Every color, size, radius, and transition is a --bsb-* custom property. Override with the theme prop, plain CSS, or per-slot classNames.
  • 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-motion support.
  • SSR-safe. No window/document access 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-sidebar
import { 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 all

Every 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 its light/dark split.
  • 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 (return undefined to fall through to the default).
  • Item-level render on { 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