Skip to content
ilinxa/pro-ui

App Sidebar

alphav0.4.0

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

Category: NavigationUpdated: 2026-08-11Created: 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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
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

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

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
  • 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