App Sidebar
alphav0.4.0App-shell sidebar with mobile drawer mode, twelve composition slots, prefab nav parts, and a headless state hook.
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
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/app-sidebarAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/app-sidebar-fixturesPreview
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)
Active path: /social/home
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
biz-acme · slug: acmeActive 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/Billinghide unlessisOwnerTeamhidden unlesscurrentMaxMembers ≥ 2bypassFilteringreveals everything (still respectshidden:true)collapse sidebarthreadsisCollapsedinto 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
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 flatNavItem[]OR mixedNavEntry[](items / sections / separators)currentPath+ optionalisActivepredicate drive active-row detection (registry-portable — no router coupling)linkComponent— pass your router's link primitive (default<a href>)storageKeyopt-in localStorage persist of collapse + section-collapse stateactiveVariant—"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 anAccountSwitcher/ governance bar / status banner. Renders nothing when omitted (zero layout shift vs v0.1).hrefTemplateValues— map of{key}placeholders substituted in everyNavItem.href. e.g.{ slug: 'acme' }turns/biz/{slug}/teaminto/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 stableuseCallback.NavItem.ownerOnly+ sidebar propisOwner— hides the item unlessisOwneris true. Pairs with the existingpermissiongate; both must pass (intersection).NavItem.minMembers+ sidebar propcurrentMaxMembers— 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: trueis still respected.useFilteredNavSections({ sections, permissions?, isOwner?, currentMaxMembers?, bypassFiltering? })— pure helper hook returning the filteredNavEntry[]. 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 shipuseNavContext— 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
- F-cross-13 defensive: Sheet + DropdownMenu callbacks pre-emptively widened (Tooltip primitive dropped in v0.3.2 — local implementation)