Skip to content
ilinxa/pro-ui

App Sidebar

alphav0.4.1

App-shell sidebar with mobile drawer mode, twelve composition slots, prefab nav parts, and a headless state hook.

Category: NavigationUpdated: 2026-08-19Created: 2026-05-22Author: ilinxa

Context

App-shell navigation for SaaS dashboards, social products, and developer tools. Single source of truth for the collapsible-left-sidebar pattern: built-in collapse + mobile drawer (Sheet) + tooltips-on-collapsed + sections + separators + permissions + localStorage persist + CSS-variable theme surface. Replaces the per-app reinvention of these 30 affordances. Sibling-of bottom-tab-bar-01 (shares NavBadge part + NavItem schema via cross-procomp relative imports). Migration origin: kasder's SocialSidebar.tsx.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/app-sidebar

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/app-sidebar-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

activeVariant:

Full kasder recipe — brand · items · primary action · user footer

Active path: /social/home
Toggle collapse (top-right at lg+) to see brand/labels hide, badges flip to corner, tooltip shows on hover, footer dropdown align flips to center. Below lg the sidebar opens as a drawer — tap the hamburger above.

Flat list (no chrome)

Sections + separators + collapsible groups

Active path: /projects

v0.3.0 — renderItem slot (wraps defaultRender in TooltipWrapper)

Hover any row — the consumer-supplied TooltipWrapper wraps the library's default link. Inspect the DOM: each row is a SINGLE <li> (no double-nesting).
Active path: /social/home

v0.2.0 — multi-context: topSlot + {slug} templates + ownerOnly + minMembers + bypassFiltering

context: biz-acme · slug: acme

Active context: biz-acme

Active path: /bconsole/acme/dashboard

Items in this context: 6

  • Switch context in the popover — items + default path swap
  • Business contexts use {slug} in hrefs (Acme vs Globex)
  • Analytics / Settings / Billing hide unless isOwner
  • Team hidden unless currentMaxMembers ≥ 2
  • bypassFiltering reveals everything (still respects hidden:true)
  • collapse sidebar threads isCollapsed into BOTH<AppSidebar> AND the slotted <AccountSwitcher> — switcher trigger flips to icon-only along with the rest of the sidebar

v0.2.0 — headless useFilteredNavSections (no <AppSidebar>)

  • [dashboard]Dashboardhref: /bconsole/{slug}/dashboard
  • [posts]Postshref: /bconsole/{slug}/posts
  • [team]Teamhref: /bconsole/{slug}/team

Renders consumer-owned UI; library helper just does the filter math.

Demo source

demo.tsxtsx
"use client"; import {  BarChart3,  Bell,  Bookmark,  Briefcase,  Building2,  Crown,  FileText,  Globe,  Home,  LogOut,  PlusSquare,  Settings,  User as UserIcon,  Users,} from "lucide-react";import { useMemo, useRef, useState } from "react";import { AccountSwitcher } from "../account-switcher/account-switcher";import type { SwitcherItem } from "../account-switcher/types";import { AppSidebar } from "./app-sidebar";import { useFilteredNavSections } from "./hooks/use-filtered-nav-sections";import { AppSidebarTrigger } from "./parts/sidebar-nav-trigger";import { TooltipWrapper } from "./parts/tooltip-wrapper";import {  SIDEBAR_NAV_DUMMY_ITEMS,  SIDEBAR_NAV_DUMMY_SECTIONED,} from "./dummy-data";import type {  NavEntry,  AppSidebarHandle,  AppSidebarProps,} from "./types"; const KSquareLogo = () => (  <span className="flex h-8 w-8 items-center justify-center rounded-md bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg) text-sm font-bold">    K  </span>); export default function AppSidebarDemo() {  const [path, setPath] = useState("/social/home");  const [sectionedPath, setSectionedPath] = useState("/projects");  const [recipePath, setRecipePath] = useState("/social/home");  const [variant, setVariant] =    useState<NonNullable<AppSidebarProps["activeVariant"]>>("fill");  const kasderRecipeRef = useRef<AppSidebarHandle>(null);  const flatListRef = useRef<AppSidebarHandle>(null);  const sectionedRef = useRef<AppSidebarHandle>(null);   const interceptClick =    (setter: (p: string) => void) =>    ({ item, event }: { item: { href?: string }; event: React.MouseEvent }) => {      if (item.href) {        event.preventDefault();        setter(item.href);      }    };   return (    <div className="flex flex-col gap-6">      {/* Live activeVariant switcher */}      <div className="flex flex-wrap items-center gap-2">        <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">          activeVariant:        </span>        {(          ["fill", "left-bar", "right-bar", "outline", "subtle"] as const        ).map((v) => (          <button            key={v}            type="button"            onClick={() => setVariant(v)}            className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${              variant === v                ? "bg-primary text-primary-foreground"                : "bg-muted text-muted-foreground hover:bg-muted/80"            }`}          >            {v}          </button>        ))}      </div>       {/* Full kasder recipe — brand + items + primary action + footer */}      <div>        <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">          Full kasder recipe — brand · items · primary action · user footer        </p>        <div className="flex min-h-72 overflow-hidden rounded-lg border border-border bg-background lg:h-136">          <AppSidebar            ref={kasderRecipeRef}            items={SIDEBAR_NAV_DUMMY_ITEMS}            currentPath={recipePath}            onItemClick={interceptClick(setRecipePath)}            activeVariant={variant}            brand={{ logo: <KSquareLogo />, label: "Kasder", href: "/" }}            primaryAction={{              icon: PlusSquare,              label: "Post",              onClick: () => alert("Open post composer"),            }}            footer={{              user: {                name: "Alex Morgan",                handle: "@alexmorgan",                status: "online",              },              menuItems: [                { kind: "item", icon: UserIcon, label: "Profile", onClick: () => alert("profile") },                { kind: "item", icon: Settings, label: "Settings", onClick: () => alert("settings") },                { kind: "item", icon: Briefcase, label: "Business", onClick: () => alert("business") },                { kind: "separator" },                { kind: "item", icon: LogOut, label: "Log out", variant: "destructive", onClick: () => alert("logout") },              ],            }}            aria-label="Full recipe"          />          <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6">            <AppSidebarTrigger              controls={kasderRecipeRef}              aria-label="Open navigation"              className="lg:hidden"            />            <p className="text-sm text-muted-foreground sm:text-center">              Active path: <span className="font-mono">{recipePath}</span>              <br />              <span className="text-xs">                Toggle collapse (top-right at <code>lg</code>+) to see                brand/labels hide, badges flip to corner, tooltip shows on                hover, footer dropdown align flips to center. Below{" "}                <code>lg</code> the sidebar opens as a drawer — tap the                hamburger above.              </span>            </p>          </div>        </div>      </div>       {/* Flat list */}      <div>        <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">          Flat list (no chrome)        </p>        <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96">          <AppSidebar            ref={flatListRef}            items={SIDEBAR_NAV_DUMMY_ITEMS}            currentPath={path}            onItemClick={interceptClick(setPath)}            activeVariant={variant}            aria-label="Flat nav demo"          />          <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6">            <AppSidebarTrigger              controls={flatListRef}              aria-label="Open flat nav"              className="lg:hidden"            />            <p className="text-sm text-muted-foreground sm:text-center">              Active path: <span className="font-mono">{path}</span>            </p>          </div>        </div>      </div>       {/* Sectioned variant */}      <div>        <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">          Sections + separators + collapsible groups        </p>        <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96">          <AppSidebar            ref={sectionedRef}            items={SIDEBAR_NAV_DUMMY_SECTIONED}            currentPath={sectionedPath}            onItemClick={interceptClick(setSectionedPath)}            activeVariant={variant}            aria-label="Sectioned nav demo"          />          <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6">            <AppSidebarTrigger              controls={sectionedRef}              aria-label="Open sectioned nav"              className="lg:hidden"            />            <p className="text-sm text-muted-foreground sm:text-center">              Active path: <span className="font-mono">{sectionedPath}</span>            </p>          </div>        </div>      </div>       <V03RenderItemSlotDemo variant={variant} />      <V02MultiContextDemo />      <V02HeadlessFilterDemo />    </div>  );} // ─────────────────────────────────────────────────────────────────────────// v0.3.0 — renderItem slot demo (C1 + C6 regression anchor)//// Demonstrates the load-bearing pattern: wrap the library's defaultRender// in a consumer-supplied affordance. Pre-v0.3.0, returning `defaultRender`// produced double-nested <li><li>...</li></li>. The C1 ownership inversion// fix means the <li> is always owned by the library — consumer's renderItem// return value goes inside that one <li>.// ───────────────────────────────────────────────────────────────────────── function V03RenderItemSlotDemo({  variant,}: {  variant: NonNullable<AppSidebarProps["activeVariant"]>;}) {  const [renderItemPath, setRenderItemPath] = useState("/social/home");  const renderItemRef = useRef<AppSidebarHandle>(null);  return (    <div>      <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">        v0.3.0 — renderItem slot (wraps defaultRender in TooltipWrapper)      </p>      <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96">        <AppSidebar          ref={renderItemRef}          items={SIDEBAR_NAV_DUMMY_ITEMS}          currentPath={renderItemPath}          onItemClick={({ item, event }) => {            if (item.href) {              event.preventDefault();              setRenderItemPath(item.href);            }          }}          activeVariant={variant}          renderItem={({ defaultRender, item, isCollapsed }) => (            <TooltipWrapper              content={                <div className="flex flex-col gap-0.5">                  <span className="font-medium">{item.label}</span>                  <span className="text-[10px] text-muted-foreground">                    Slot-wrapped via renderItem                  </span>                </div>              }              side="right"              // collapsed rail already shows defaultRender's own tooltip —              // disable this one so the two don't stack              disabled={isCollapsed}            >              {defaultRender}            </TooltipWrapper>          )}          aria-label="renderItem slot demo"        />        <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6">          <AppSidebarTrigger            controls={renderItemRef}            aria-label="Open renderItem demo"            className="lg:hidden"          />          <p className="text-sm text-muted-foreground sm:text-center">            Hover any row — the consumer-supplied TooltipWrapper wraps the            library&apos;s default link. Inspect the DOM: each row is a            SINGLE <code>&lt;li&gt;</code> (no double-nesting).            <br />            Active path: <span className="font-mono">{renderItemPath}</span>          </p>        </div>      </div>    </div>  );} // ─────────────────────────────────────────────────────────────────────────// v0.2.0 — Multi-context demo (topSlot + {slug} + ownerOnly + minMembers + bypass)//// Demonstrates the *real* v0.2 power: switching contexts in the topSlot// account-switcher changes BOTH the nav-item catalog AND the {slug} value// fed into href substitution. Each context has its own item set — matches// the source app-shell pattern (analysis §8.2 NavContext discriminant).// ───────────────────────────────────────────────────────────────────────── type V02ContextKey = "personal" | "biz-acme" | "biz-globex" | "platform"; const V02_SWITCHER_ITEMS: ReadonlyArray<SwitcherItem> = [  { key: "personal", label: "Personal", icon: UserIcon, href: "/home" },  { key: "biz-acme", label: "Acme Corp", icon: Building2, href: "/bconsole/acme" },  { key: "biz-globex", label: "Globex Industries", icon: Building2, href: "/bconsole/globex" },  { key: "platform", label: "Platform", icon: Globe, href: "/pconsole/overview" },]; // Per-context nav catalogs. Switching contexts swaps the entire item set;// {slug} substitution applies only when the context provides a slug.const V02_NAV_BY_CONTEXT: Record<V02ContextKey, ReadonlyArray<NavEntry>> = {  personal: [    { id: "home", label: "Home", icon: Home, href: "/home" },    { id: "profile", label: "Profile", icon: UserIcon, href: "/profile" },    { id: "notifications", label: "Notifications", icon: Bell, href: "/notifications", badge: 3 },    { id: "saved", label: "Saved items", icon: Bookmark, href: "/saved" },  ],  "biz-acme": [    { id: "dashboard", label: "Dashboard", icon: Briefcase, href: "/bconsole/{slug}/dashboard" },    { id: "posts", label: "Posts", icon: FileText, href: "/bconsole/{slug}/posts" },    { id: "team", label: "Team", icon: Users, href: "/bconsole/{slug}/team", minMembers: 2 },    { id: "analytics", label: "Analytics", icon: BarChart3, href: "/bconsole/{slug}/analytics", ownerOnly: true },    { id: "settings", label: "Settings", icon: Settings, href: "/bconsole/{slug}/settings", ownerOnly: true },    { id: "billing", label: "Billing", icon: Crown, href: "/bconsole/{slug}/billing", ownerOnly: true },  ],  "biz-globex": [    { id: "dashboard", label: "Dashboard", icon: Briefcase, href: "/bconsole/{slug}/dashboard" },    { id: "posts", label: "Posts", icon: FileText, href: "/bconsole/{slug}/posts" },    { id: "team", label: "Team", icon: Users, href: "/bconsole/{slug}/team", minMembers: 2 },    { id: "analytics", label: "Analytics", icon: BarChart3, href: "/bconsole/{slug}/analytics", ownerOnly: true },    { id: "settings", label: "Settings", icon: Settings, href: "/bconsole/{slug}/settings", ownerOnly: true },    { id: "billing", label: "Billing", icon: Crown, href: "/bconsole/{slug}/billing", ownerOnly: true },  ],  platform: [    { id: "overview", label: "Overview", icon: Globe, href: "/pconsole/overview" },    { id: "users", label: "All users", icon: Users, href: "/pconsole/users", ownerOnly: true },    { id: "audit", label: "Audit log", icon: FileText, href: "/pconsole/audit", ownerOnly: true },    { id: "platform-settings", label: "Platform settings", icon: Settings, href: "/pconsole/settings", ownerOnly: true },  ],}; const V02_DEFAULT_PATH_BY_CONTEXT: Record<V02ContextKey, string> = {  personal: "/home",  "biz-acme": "/bconsole/acme/dashboard",  "biz-globex": "/bconsole/globex/dashboard",  platform: "/pconsole/overview",}; function V02MultiContextDemo() {  const [activeContextKey, setActiveContextKey] = useState<V02ContextKey>("biz-acme");  const [isOwner, setIsOwner] = useState(true);  const [maxMembers, setMaxMembers] = useState(5);  const [bypass, setBypass] = useState(false);  const [currentPath, setCurrentPath] = useState(V02_DEFAULT_PATH_BY_CONTEXT["biz-acme"]);  // Lift collapsed state so it threads to BOTH <AppSidebar> AND the slotted  // <AccountSwitcher> — the canonical collapse-aware composition recipe.  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);  const v02SidebarRef = useRef<AppSidebarHandle>(null);   // Items + slug derive purely from the current context.  const items = V02_NAV_BY_CONTEXT[activeContextKey];   const slug = activeContextKey.startsWith("biz-")    ? activeContextKey.slice(4)    : undefined;   const templateValues = useMemo(    () => (slug ? { slug } : undefined),    [slug],  );   const handleContextSwitch = (item: SwitcherItem) => {    const next = item.key as V02ContextKey;    setActiveContextKey(next);    setCurrentPath(V02_DEFAULT_PATH_BY_CONTEXT[next]);  };   return (    <div>      <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">        v0.2.0 — multi-context: topSlot + &#123;slug&#125; templates + ownerOnly + minMembers + bypassFiltering      </p>      <div className="mb-3 flex flex-wrap items-center gap-4 rounded-md border border-border bg-card p-3 text-xs">        <label className="flex items-center gap-2">          <input            type="checkbox"            checked={isOwner}            onChange={(e) => setIsOwner(e.target.checked)}          />          isOwner        </label>        <label className="flex items-center gap-2">          currentMaxMembers:          <input            type="number"            min={0}            max={50}            value={maxMembers}            onChange={(e) => setMaxMembers(Number(e.target.value) || 0)}            className="w-16 rounded border border-border bg-background px-2 py-0.5"          />        </label>        <label className="flex items-center gap-2">          <input            type="checkbox"            checked={bypass}            onChange={(e) => setBypass(e.target.checked)}          />          bypassFiltering        </label>        <label className="flex items-center gap-2">          <input            type="checkbox"            checked={sidebarCollapsed}            onChange={(e) => setSidebarCollapsed(e.target.checked)}          />          collapse sidebar        </label>        <span className="ml-auto font-mono text-muted-foreground">          context: <code>{activeContextKey}</code>          {slug ? (            <>              {" "}· slug: <code>{slug}</code>            </>          ) : null}        </span>      </div>      <div className="flex min-h-72 overflow-hidden rounded-lg border border-border bg-background lg:h-136">        <AppSidebar          ref={v02SidebarRef}          items={items}          currentPath={currentPath}          isCollapsed={sidebarCollapsed}          onCollapsedChange={({ collapsed }) => setSidebarCollapsed(collapsed)}          onItemClick={({ item, event }) => {            if (item.href) {              event.preventDefault();              setCurrentPath(item.href);            }          }}          topSlot={            <AccountSwitcher              items={V02_SWITCHER_ITEMS}              activeKey={activeContextKey}              onSelect={handleContextSwitch}              isCollapsed={sidebarCollapsed}            />          }          hrefTemplateValues={templateValues}          isOwner={isOwner}          currentMaxMembers={maxMembers}          bypassFiltering={bypass}          aria-label="v0.2 multi-context demo"        />        <div className="flex flex-1 flex-col gap-2 p-4 text-sm text-muted-foreground sm:p-6">          <AppSidebarTrigger            controls={v02SidebarRef}            aria-label="Open v0.2 multi-context nav"            className="self-start lg:hidden"          />          <p>Active context: <code className="font-mono">{activeContextKey}</code></p>          <p>Active path: <code className="font-mono break-all">{currentPath}</code></p>          <p className="text-xs">Items in this context: <code>{items.length}</code></p>          <ul className="ml-4 list-disc space-y-1 text-xs">            <li>Switch context in the popover — items + default path swap</li>            <li>Business contexts use <code>&#123;slug&#125;</code> in hrefs (Acme vs Globex)</li>            <li><code>Analytics</code> / <code>Settings</code> / <code>Billing</code> hide unless <code>isOwner</code></li>            <li><code>Team</code> hidden unless <code>currentMaxMembers ≥ 2</code></li>            <li><code>bypassFiltering</code> reveals everything (still respects <code>hidden:true</code>)</li>            <li>              <code>collapse sidebar</code> threads <code>isCollapsed</code> into BOTH              <code>&lt;AppSidebar&gt;</code> AND the slotted{" "}              <code>&lt;AccountSwitcher&gt;</code> — switcher trigger flips to              icon-only along with the rest of the sidebar            </li>          </ul>        </div>      </div>    </div>  );} // ─────────────────────────────────────────────────────────────────────────// v0.2.0 — Headless useFilteredNavSections (standalone, no <AppSidebar>)// ───────────────────────────────────────────────────────────────────────── function V02HeadlessFilterDemo() {  const [isOwner, setIsOwner] = useState(false);  const filtered = useFilteredNavSections({    sections: V02_NAV_BY_CONTEXT["biz-acme"],    isOwner,    currentMaxMembers: 10,  });  return (    <div>      <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">        v0.2.0 — headless useFilteredNavSections (no &lt;AppSidebar&gt;)      </p>      <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 text-sm">        <label className="flex items-center gap-2 text-xs">          <input            type="checkbox"            checked={isOwner}            onChange={(e) => setIsOwner(e.target.checked)}          />          isOwner (toggle to flip Settings + Billing visibility)        </label>        <ul className="flex flex-col gap-1">          {filtered.map((entry, index) =>            entry.kind === "section" ? null : entry.kind === "separator" ? (              <li key={entry.id ?? `sep-${index}`} className="my-1 h-px bg-border" />            ) : (              <li                key={entry.id}                className="flex flex-col gap-0.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2"              >                <span className="flex items-center gap-2">                  <span className="font-mono text-xs text-muted-foreground">[{entry.id}]</span>                  <span>{entry.label}</span>                </span>                <span className="min-w-0 text-xs text-muted-foreground sm:ml-auto">                  href:{" "}                  <code className="break-all">{entry.href}</code>                </span>              </li>            ),          )}        </ul>        <p className="text-xs text-muted-foreground">          Renders consumer-owned UI; library helper just does the filter math.        </p>      </div>    </div>  );} 

Usage

Status

C1 (scaffold + types) landed. Items + collapse + drawer + slots roll out across C2–C13. The full AppSidebarProps surface is already typed — your call sites compile against the final shape now.

When to use

Reach for AppSidebar for any desktop app shell that needs a collapsible left (or right) navigation column with a mobile drawer fallback. Replaces the per-app reinvention of: collapse + sections + badges + tooltips-on-collapsed + permission gating + localStorage persist + reduced-motion + WAI-ARIA.

Basic example

import { AppSidebar, type NavItem } from "@ilinxa/app-sidebar";
import { usePathname } from "next/navigation";
import Link from "next/link";

const items: NavItem[] = [
  { id: "home", label: "Home", href: "/" },
  { id: "inbox", label: "Inbox", href: "/inbox", badge: 12 },
];

export function AppShell({ children }) {
  const pathname = usePathname();
  return (
    <div className="flex min-h-screen">
      <AppSidebar
        items={items}
        currentPath={pathname}
        linkComponent={({ href, children, ...rest }) => (
          <Link href={href} {...rest}>{children}</Link>
        )}
      />
      <main className="flex-1">{children}</main>
    </div>
  );
}

Key props

  • items — accepts flat NavItem[] OR mixed NavEntry[] (items / sections / separators)
  • currentPath + optional isActive predicate drive active-row detection (registry-portable — no router coupling)
  • linkComponent— pass your router's link primitive (default <a href>)
  • storageKey opt-in localStorage persist of collapse + section-collapse state
  • activeVariant — "fill" (default) / "left-bar" / "right-bar" / "outline" / "subtle"

v0.2.0 — additions

v0.2.0 is strictly additive on v0.1 — every existing consumer compiles unchanged. New surface unlocks multi-tenant SaaS shells:

  • topSlot — single slot ABOVE the brand row for an AccountSwitcher / governance bar / status banner. Renders nothing when omitted (zero layout shift vs v0.1).
  • hrefTemplateValues — map of {key} placeholders substituted in every NavItem.href. e.g. { slug: 'acme' } turns /biz/{slug}/team into /biz/acme/team.
  • resolveHref(item, values) — escape-hatch callback; wins precedence over the built-in substitution. Use for subdomain rewrites / locale prefixes / conditional sub-paths. Should be a stable useCallback.
  • NavItem.ownerOnly + sidebar prop isOwner — hides the item unless isOwner is true. Pairs with the existing permission gate; both must pass (intersection).
  • NavItem.minMembers + sidebar prop currentMaxMembers— hides the item unless plan-tier seat capacity meets the threshold. Useful for “Members tab only on plans with ≥N seats”.
  • bypassFiltering — when true, skips ALL permission gates (permission ∩ ownerOnly ∩ minMembers) at BOTH section + item levels. hidden: true is still respected.
  • useFilteredNavSections({ sections, permissions?, isOwner?, currentMaxMembers?, bypassFiltering? }) — pure helper hook returning the filtered NavEntry[]. NOT coupled to <AppSidebar> — render your own arbitrary sidebar UI with this hook standalone.
  • type NavContext — exported discriminated union covering personal / business / platform / governance / cms-platform / cms-business. Type-only; use it to type your URL→context derivation. (Library does NOT ship useNavContext— that's your router's concern.)

Composition recipe — drop <AccountSwitcher> into topSlot; thread the current context's slug into hrefTemplateValues; pass isOwner + currentMaxMembers from your auth store. Zero hard registry dep between app-sidebar and account-switcher.

Collapse-aware composition (responsive)

app-sidebar is viewport-aware (built-in mobile drawer below mobileBreakpoint) AND container-aware via isCollapsed (icon-only desktop mode). The slotted <AccountSwitcher>is NOT viewport-aware on its own — it's a primitive, not an app-shell. The recipe is to LIFT the collapsed state so it threads into both:

const [sidebarCollapsed, setSidebarCollapsed] = useState(false);

<AppSidebar
  items={items}
  currentPath={pathname}
  isCollapsed={sidebarCollapsed}
  onCollapsedChange={({ collapsed }) => setSidebarCollapsed(collapsed)}
  topSlot={
    <AccountSwitcher
      items={switcherItems}
      activeKey={activeKey}
      onSelect={onSelect}
      isCollapsed={sidebarCollapsed}   // ← passthrough; trigger flips icon-only
    />
  }
/>

Below mobileBreakpoint the sidebar becomes a Sheet drawer; inside that drawer pass isCollapsed={false} (the drawer renders the sidebar full-width on mobile). Full recipe in account-switcher-procomp-guide.md §4.6.

Features

  • Collapsible (uncontrolled / controlled / headless-via-hook)
  • Mobile-drawer mode via shadcn Sheet (CSS-gated render path; no SSR flash)
  • <AppSidebarTrigger> companion for hamburger button outside sidebar subtree
  • Items discriminated union: NavItem | NavSection | NavSeparator
  • v0.2 — topSlot above brand zone for AccountSwitcher / context widgets
  • v0.2 — {key} href template substitution + resolveHref callback escape hatch
  • v0.2 — ownerOnly + minMembers gates (three-gate intersection: permission ∩ ownerOnly ∩ minMembers)
  • v0.2 — bypassFiltering at BOTH section + item levels for personal-context / debug views
  • v0.2 — exported NavContext discriminated union (type-only)
  • v0.2 — exported useFilteredNavSections hook (works standalone, not coupled to <AppSidebar>)
  • v0.3 — renderItem slot wraps consumer-supplied content in a single <li> (fixes v0.2.x double-nest bug)
  • v0.3 — onMobileOpenChange.reason discriminator correctly fires trigger / item-click / outside-click / escape / imperative
  • v0.3 — openMobile / closeMobile / toggleMobile accept optional reason? param
  • v0.3 — NavUserMenuItem.onClick widened to Event | React.MouseEvent (exported as NavUserMenuItemSelectEvent)
  • v0.3.2 (2026-08-11) — F-cross-13 path-b sweep: zero asChild on shadcn primitives (NavUser trigger IS the DropdownMenuTrigger; href menu rows nest the anchor inside the item; NavPrimaryAction href path uses buttonVariants on the link); collapsed-rail tooltip reimplemented locally — delay honored cross-backend, no Radix-only delayDuration. Zero public-API change.
  • Active-route detection: currentPath + isActive predicate + per-item match
  • linkComponent abstraction (router-agnostic — Next.js, React Router, TanStack)
  • 13 slots (named + render-prop, incl. v0.2 topSlot) + 4 prefab parts (NavBadge, NavBrand, NavPrimaryAction, NavUser)
  • 5 active-state variants (fill / left-bar / right-bar / outline / subtle)
  • CSS-variable theme surface (--ilinxa-sidebar-*) for any-scope theming
  • Section auto-expand when active item inside + auto-scroll into view
  • Permissions membership gating + diff-based onPermissionDenied
  • localStorage opt-in persist for collapse + collapsed-sections
  • Full WAI-ARIA pattern, keyboard nav, skip-link, reduced-motion respect
  • v0.4.1 — `onItemHover` / `onItemFocus` / `onActiveItemChange` / `onMount` / `onUnmount` are wired and emit; they were declared on the props surface but connected to nothing until now. Hover/focus stay silent on disabled rows; `onActiveItemChange` fires from derived state when the host's path commits, never from the click handler
  • F-cross-13 defensive: Sheet + DropdownMenu callbacks pre-emptively widened (Tooltip primitive dropped in v0.3.2 — local implementation)

Tags

sidebarnavigationapp-shellcollapsibledrawermobileheadlesscontrolled-uncontrolledpermissionstheming

Dependencies

shadcn primitives: sheet, avatar, button, dropdown-menu
npm peer deps: lucide-react@^1.11.0