jengacore.shadcn registry · Tailwind v4 · Radix and Base UI
Components
jengacore components · @jengacore/app-sidebar

AppSidebar

Navy sidebar in every theme with the product wordmark, signature marker, ⌘K button and a 64px rail.

Install

terminal
npx shadcn@latest add @jengacore/app-sidebar

Without the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/app-sidebar.json

npm packages

lucide-react

Source

components/jc/app-sidebar.tsx
"use client"

import * as React from "react"
import { ChevronDown, ChevronsLeft, ChevronsRight, Search } from "lucide-react"

import { cn } from "@/lib/utils"
import { PRODUCT_NAME, PRODUCT_TILE, PRODUCT_WORDMARK, type Product } from "@/components/jc/theme"

export type NavItem = {
  label: string
  icon: React.ComponentType<{ className?: string }>
  href: string
  count?: number
  current?: boolean
}
export type NavGroup = { label: string; items: NavItem[] }

type LinkProps = { href: string; className?: string; title?: string; "aria-current"?: "page"; children: React.ReactNode }

/**
 * The jengacore sidebar: navy in every theme, product wordmark top-left, signature-colour marker on the
 * current item, "by jengacore." at the foot. Collapses to a 64px icon rail (labels stay as sr-only text
 * and tooltips). Pass `linkAs={Link}` from next/link for client navigation.
 */
export function AppSidebar({
  product,
  groups,
  user,
  collapsed = false,
  onCollapsedChange,
  onSearch,
  onSwitchProduct,
  linkAs: LinkComp = "a",
  footer,
  className,
}: {
  product: Product
  groups: NavGroup[]
  user?: { name: string; role: string; initials: string }
  collapsed?: boolean
  onCollapsedChange?: (collapsed: boolean) => void
  onSearch?: () => void
  /** Shows the chevron next to the wordmark (product switcher for people who use several products). */
  onSwitchProduct?: () => void
  linkAs?: React.ElementType<LinkProps>
  /** Extra content above the user block (tenant switcher, help link). */
  footer?: React.ReactNode
  className?: string
}) {
  return (
    <nav
      data-slot="app-sidebar"
      data-collapsed={collapsed || undefined}
      aria-label={PRODUCT_NAME[product]}
      className={cn(
        "flex h-svh shrink-0 flex-col gap-0.5 overflow-y-auto bg-sidebar px-3 pt-4 pb-3.5 text-sidebar-foreground transition-[width] duration-base ease-jc",
        collapsed ? "w-16" : "w-[236px]",
        className
      )}
    >
      <div className={cn("flex items-center pt-0.5 pb-[18px]", collapsed ? "justify-center" : "justify-between px-2")}>
        {collapsed ? (
          <img src={PRODUCT_TILE[product]} alt={PRODUCT_NAME[product]} className="size-8" />
        ) : (
          <>
            <img src={PRODUCT_WORDMARK[product]} alt={PRODUCT_NAME[product]} className="h-[22px] w-auto" />
            {onSwitchProduct ? (
              <button
                type="button"
                onClick={onSwitchProduct}
                aria-label="Switch product"
                className="inline-flex rounded-sm p-1 text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]"
              >
                <ChevronDown className="size-4 stroke-[1.75]" />
              </button>
            ) : null}
          </>
        )}
      </div>

      {onSearch ? (
        <button
          type="button"
          onClick={onSearch}
          className={cn(
            "mb-2 flex h-9 items-center gap-2.5 rounded-md border border-sidebar-border px-2.5 text-[13px] text-sidebar-muted transition-colors duration-fast hover:border-sidebar-foreground/40 hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]",
            collapsed && "justify-center px-0"
          )}
        >
          <Search className="size-4 stroke-[1.75]" aria-hidden />
          {collapsed ? (
            <span className="sr-only">Search and commands</span>
          ) : (
            <>
              <span>Search or jump to…</span>
              <kbd className="ml-auto rounded-[3px] border border-sidebar-border px-1 font-mono text-[10.5px]">⌘K</kbd>
            </>
          )}
        </button>
      ) : null}

      {groups.map((g) => (
        <React.Fragment key={g.label}>
          {collapsed ? (
            <div className="mx-2 my-2 h-px bg-sidebar-border" />
          ) : (
            <div className="px-2 pt-3.5 pb-1.5 font-mono text-[10px] leading-4 tracking-[0.16em] text-sidebar-muted uppercase">{g.label}</div>
          )}
          {g.items.map((it) => (
            <LinkComp
              key={it.href + it.label}
              href={it.href}
              title={collapsed ? it.label : undefined}
              aria-current={it.current ? "page" : undefined}
              className={cn(
                "relative flex items-center gap-2.5 rounded-md px-2 py-[7px] text-sm transition-colors duration-fast ease-jc",
                "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]",
                "aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-primary",
                "aria-[current=page]:before:absolute aria-[current=page]:before:inset-y-[7px] aria-[current=page]:before:-left-3 aria-[current=page]:before:w-[3px] aria-[current=page]:before:rounded-r-[3px] aria-[current=page]:before:bg-product",
                collapsed && "justify-center"
              )}
            >
              <it.icon className="size-4 shrink-0 stroke-[1.75]" />
              {collapsed ? <span className="sr-only">{it.label}</span> : <span className="truncate">{it.label}</span>}
              {!collapsed && it.count !== undefined ? <span className="ml-auto font-mono text-[11px] text-sidebar-muted">{it.count}</span> : null}
            </LinkComp>
          ))}
        </React.Fragment>
      ))}

      <div className="mt-auto" />
      {footer}
      {user ? (
        <div className={cn("flex items-center gap-2.5 border-t border-sidebar-border pt-3", collapsed ? "justify-center" : "px-2")}>
          <span className="grid size-[30px] shrink-0 place-items-center rounded-full bg-sidebar-accent text-[11px] font-semibold text-sidebar-primary">
            {user.initials}
          </span>
          {collapsed ? (
            <span className="sr-only">{user.name}</span>
          ) : (
            <span className="grid min-w-0 text-[13px] leading-[17px]">
              <span className="truncate">{user.name}</span>
              <span className="truncate text-xs text-sidebar-muted">{user.role}</span>
            </span>
          )}
        </div>
      ) : null}
      <div className={cn("flex items-center pt-2.5", collapsed ? "justify-center" : "justify-between px-2")}>
        {!collapsed && (
          <span className="flex items-center gap-1.5 text-[11px] text-sidebar-muted">
            by <img src="/brand/jengacore-wordmark-white.svg" alt="jengacore." className="h-[11px] w-auto opacity-75" />
          </span>
        )}
        {onCollapsedChange ? (
          <button
            type="button"
            onClick={() => onCollapsedChange(!collapsed)}
            className="grid size-7 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]"
            aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
          >
            {collapsed ? <ChevronsRight className="size-4" /> : <ChevronsLeft className="size-4" />}
          </button>
        ) : null}
      </div>
    </nav>
  )
}

const collapseListeners = new Set<() => void>()

function readCollapsed(key: string): boolean {
  try {
    return localStorage.getItem(key) === "1"
  } catch {
    return false
  }
}

/**
 * Remembers the collapsed state per user (localStorage). A shared external store, so every sidebar
 * reads the same value and the server render (expanded) never fights the stored one.
 */
export function useSidebarCollapsed(key = "jc-sidebar-collapsed") {
  const subscribe = React.useCallback((listener: () => void) => {
    collapseListeners.add(listener)
    return () => {
      collapseListeners.delete(listener)
    }
  }, [])
  const collapsed = React.useSyncExternalStore(
    subscribe,
    () => readCollapsed(key),
    () => false
  )
  const update = React.useCallback(
    (value: boolean) => {
      try {
        localStorage.setItem(key, value ? "1" : "0")
      } catch {
        /* storage unavailable: the choice lasts until reload */
      }
      collapseListeners.forEach((listener) => listener())
    },
    [key]
  )
  return [collapsed, update] as const
}