bottega

sections

App Shell

Next.js App Router dashboard chrome: a 64px icon-rail sidebar that expands to 220px on hover or keyboard focus, a 56px topbar whose title each page publishes via PageHeader, and a mobile drawer with scrim. Nav is a plain ModuleDef[] you own; bring your own auth and pass the resolved user as a prop. Next-only (uses next/link + next/navigation).

Overview

Bookings
128
Revenue
128
Fleet
128

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/app-shell

Usage

Usagetsx
// modules.ts — you own this array; filter by permission on the server.
import { LayoutDashboard, Table2, Settings } from "lucide-react";
import type { ModuleDef } from "@/components/module-registry";

export const MODULES: ModuleDef[] = [
  { slug: "overview", label: "Overview", href: "/", group: "Home", icon: LayoutDashboard },
  { slug: "orders", label: "Orders", href: "/orders", group: "Work", icon: Table2 },
  { slug: "settings", label: "Settings", href: "/settings", group: "Admin", icon: Settings },
];

// app/(app)/layout.tsx — render the shell ONCE around the route group.
import { AppShell } from "@/components/app-shell";
import { MODULES } from "@/lib/modules";

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell brand={{ name: "Acme", tagline: "Operations" }} modules={MODULES} user={{ name: "Ada" }}>
      {children}
    </AppShell>
  );
}

// Any page — publish its topbar title:
// <PageHeader title="Orders" meta="128 open" />

// Brand palette: override the token VALUES in your globals.css (:root / .dark) —
// the shell only ever reads the 11 contract tokens.

Props

PropTypeDefaultDescription
brand*{ name: string; tagline?: string; logo?: ReactNode; }
modules*ModuleDef[]Pre-filtered nav — group order follows first appearance in this array.
user{ name: string; email?: string; label?: string; }
onSignOut() => void
userMenuReactNodeReplaces the default sign-out button in the user block when provided.
topbarActionsReactNodeRight side of the topbar (before the page's own `right` slot).
paletteReactNodeSlot for a command palette trigger (e.g. @bottega /command-palette).
activePathstringOverride the active path (defaults to usePathname()) — used by previews.
classNamestringExtra classes on the root (e.g. a fixed height in an embedded preview).

Source

app-shell.tsxtsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { LogOut, Menu, X } from "lucide-react";
import {
  moduleForPath,
  moduleGroups,
  modulesByGroup,
  type ModuleDef,
} from "./module-registry";
import { PageChromeProvider, usePageChrome } from "./page-chrome";

/**
 * Next.js App Router dashboard chrome. Render ONCE from a route-group layout
 * so the sidebar + topbar survive client-side navigation:
 *
 *   <AppShell brand={{ name: "Acme" }} modules={MODULES} user={user}>
 *     {children}
 *   </AppShell>
 *
 * Bring your own auth: resolve the user (and filter `modules` by permission)
 * on the server, pass the results down. Desktop sidebar is a 64px icon rail
 * that expands to 220px on hover or keyboard focus; mobile gets a drawer.
 */
export interface AppShellProps {
  brand: { name: string; tagline?: string; logo?: ReactNode };
  /** Pre-filtered nav — group order follows first appearance in this array. */
  modules: ModuleDef[];
  user?: { name: string; email?: string; label?: string };
  onSignOut?: () => void;
  /** Replaces the default sign-out button in the user block when provided. */
  userMenu?: ReactNode;
  /** Right side of the topbar (before the page's own `right` slot). */
  topbarActions?: ReactNode;
  /** Slot for a command palette trigger (e.g. @bottega/command-palette). */
  palette?: ReactNode;
  /** Override the active path (defaults to usePathname()) — used by previews. */
  activePath?: string;
  /** Extra classes on the root (e.g. a fixed height in an embedded preview). */
  className?: string;
  children: ReactNode;
}

/**
 * Every non-contract color is derived from the 11-token contract here, once.
 * (No --muted-foreground / --card in the contract — do not add raw colors.)
 */
const SHELL_VARS = {
  "--shell-muted-fg": "color-mix(in oklch, var(--foreground) 62%, var(--background))",
  "--shell-surface-1": "color-mix(in oklch, var(--muted) 55%, var(--background))",
  "--shell-border-soft": "color-mix(in oklch, var(--border) 55%, transparent)",
  "--shell-scrim": "color-mix(in oklch, var(--foreground) 45%, transparent)",
} as CSSProperties;

export function AppShell(props: AppShellProps) {
  return (
    <PageChromeProvider>
      <ShellChrome {...props} />
    </PageChromeProvider>
  );
}

function ShellChrome({
  brand,
  modules,
  user,
  onSignOut,
  userMenu,
  topbarActions,
  palette,
  activePath,
  className,
  children,
}: AppShellProps) {
  const pathname = usePathname();
  const path = activePath ?? pathname;
  const activeModule = moduleForPath(modules, path);
  const activeSlug = activeModule?.slug ?? "";
  const { title, meta, right } = usePageChrome();
  const topbarTitle = title || activeModule?.label || "";

  const [navOpen, setNavOpen] = useState(false);
  // Desktop rail auto-hides to an icon strip and expands over the content while
  // hovered or keyboard-focused — there is no manual collapse toggle.
  const [railOpen, setRailOpen] = useState(false);
  const railCollapsed = !railOpen;

  const closeNav = useCallback(() => setNavOpen(false), []);

  useEffect(() => {
    closeNav();
  }, [path, closeNav]);

  useEffect(() => {
    if (!navOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") closeNav();
    };
    document.body.style.overflow = "hidden";
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [navOpen, closeNav]);

  // Minimal drawer focus management (v1 contract — no full tab-cycling trap):
  // opening moves focus into the drawer's close button, closing returns it to
  // the hamburger toggle. `wasOpenRef` guards the initial mount (navOpen starts
  // false) so we don't steal focus from the page on first render.
  const hamburgerRef = useRef<HTMLButtonElement>(null);
  const drawerCloseRef = useRef<HTMLButtonElement>(null);
  const wasOpenRef = useRef(false);

  useEffect(() => {
    if (navOpen) {
      drawerCloseRef.current?.focus();
    } else if (wasOpenRef.current) {
      hamburgerRef.current?.focus();
    }
    wasOpenRef.current = navOpen;
  }, [navOpen]);

  return (
    <div
      style={SHELL_VARS}
      className={`flex h-dvh overflow-hidden bg-background text-foreground relative ${className ?? ""}`}
    >
      {/* Reserve the collapsed rail's width so content never sits under the strip. */}
      <div className="hidden md:block w-[64px] shrink-0" aria-hidden="true" />
      <aside
        onMouseEnter={() => setRailOpen(true)}
        onMouseLeave={() => setRailOpen(false)}
        onFocusCapture={() => setRailOpen(true)}
        onBlurCapture={(e) => {
          // Collapse only when focus leaves the rail entirely (not between items).
          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setRailOpen(false);
        }}
        className={`hidden md:flex absolute left-0 top-0 bottom-0 z-30 bg-background border-r border-border flex-col transition-[width] duration-[var(--duration)] ease-[var(--ease)] ${
          railCollapsed
            ? "w-[64px]"
            : "w-[220px] shadow-[0_20px_40px_color-mix(in_oklch,var(--foreground)_25%,transparent)]"
        }`}
      >
        <SidebarHeader brand={brand} collapsed={railCollapsed} />
        <NavList modules={modules} activeSlug={activeSlug} collapsed={railCollapsed} />
        <UserBlock user={user} onSignOut={onSignOut} userMenu={userMenu} collapsed={railCollapsed} />
      </aside>

      <div
        onClick={closeNav}
        aria-hidden="true"
        className={`md:hidden fixed inset-0 bg-[var(--shell-scrim)] backdrop-blur-[4px] z-30 transition-opacity duration-[var(--duration)] ${
          navOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
        }`}
      />
      <aside
        role="dialog"
        aria-label="Workspace navigation"
        inert={!navOpen}
        className={`md:hidden fixed top-0 bottom-0 left-0 w-[280px] max-w-[85vw] bg-background border-r border-border z-40 flex flex-col transition-transform duration-[var(--duration)] ease-[var(--ease)] ${
          navOpen ? "translate-x-0" : "-translate-x-full"
        }`}
      >
        <SidebarHeader brand={brand} onClose={closeNav} closeButtonRef={drawerCloseRef} />
        <NavList modules={modules} activeSlug={activeSlug} onNavigate={closeNav} />
        <UserBlock user={user} onSignOut={onSignOut} userMenu={userMenu} />
      </aside>

      <main className="flex-1 flex flex-col overflow-hidden">
        <header className="h-[56px] shrink-0 border-b border-border flex items-center pr-[20px] gap-[12px]">
          <button
            ref={hamburgerRef}
            type="button"
            onClick={() => setNavOpen((v) => !v)}
            aria-label={navOpen ? "Close navigation" : "Open navigation"}
            aria-expanded={navOpen}
            className="md:hidden h-[56px] w-[56px] min-h-[44px] min-w-[44px] flex items-center justify-center text-[var(--shell-muted-fg)] hover:text-foreground hover:bg-[var(--shell-surface-1)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            {navOpen ? <X className="size-[20px]" aria-hidden="true" /> : <Menu className="size-[20px]" aria-hidden="true" />}
          </button>
          <h1 className="text-[15px] font-semibold tracking-[-0.4px] text-foreground flex-1 md:pl-[20px] truncate">
            {topbarTitle}
          </h1>
          {palette}
          {meta && (
            <span className="hidden sm:inline text-[12px] text-[var(--shell-muted-fg)] tracking-[-0.12px] tabular-nums truncate">
              {meta}
            </span>
          )}
          {topbarActions && <div className="flex items-center gap-2 shrink-0">{topbarActions}</div>}
          {right && <div className="flex items-center gap-2 shrink-0">{right}</div>}
        </header>
        <div className="flex-1 overflow-y-auto p-[16px] sm:p-[20px] pb-[max(16px,env(safe-area-inset-bottom))] sm:pb-[max(20px,env(safe-area-inset-bottom))]">
          {children}
        </div>
      </main>
    </div>
  );
}

function SidebarHeader({
  brand,
  onClose,
  closeButtonRef,
  collapsed = false,
}: {
  brand: AppShellProps["brand"];
  onClose?: () => void;
  closeButtonRef?: RefObject<HTMLButtonElement | null>;
  collapsed?: boolean;
}) {
  return (
    <div
      className={`border-b border-border flex items-center gap-[10px] ${
        collapsed ? "justify-center px-[8px] pt-[18px] pb-[14px]" : "justify-between px-[15px] pt-[18px] pb-[14px]"
      }`}
    >
      <div className="flex items-center gap-[10px] min-w-0">
        {brand.logo ?? (
          <div
            aria-hidden="true"
            className="size-[28px] shrink-0 rounded-md bg-primary text-primary-foreground flex items-center justify-center text-[13px] font-semibold uppercase"
          >
            {brand.name.slice(0, 1)}
          </div>
        )}
        {!collapsed && (
          <div className="min-w-0">
            <div className="text-[16px] font-semibold tracking-[-0.5px] text-foreground leading-none truncate">
              {brand.name}
            </div>
            {brand.tagline && (
              <div className="text-[11px] text-[var(--shell-muted-fg)] tracking-[-0.11px] mt-[5px]">
                {brand.tagline}
              </div>
            )}
          </div>
        )}
      </div>
      {onClose && (
        <button
          ref={closeButtonRef}
          type="button"
          onClick={onClose}
          aria-label="Close navigation"
          className="md:hidden -mt-[4px] -mr-[6px] h-[36px] w-[36px] flex items-center justify-center rounded-md text-[var(--shell-muted-fg)] hover:text-foreground hover:bg-[var(--shell-surface-1)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <X className="size-[16px]" aria-hidden="true" />
        </button>
      )}
    </div>
  );
}

function NavList({
  modules,
  activeSlug,
  onNavigate,
  collapsed = false,
}: {
  modules: ModuleDef[];
  activeSlug: string;
  onNavigate?: () => void;
  collapsed?: boolean;
}) {
  const groups = modulesByGroup(modules);
  const order = moduleGroups(modules);

  // Flat list of items per group, groups separated by a thin divider — no
  // headers, no per-group collapse. Labels surface via the item title tooltip
  // when the rail is collapsed to the icon strip.
  return (
    <nav className={`flex-1 overflow-y-auto ${collapsed ? "p-[8px_8px_4px]" : "p-[10px_8px_4px]"}`}>
      {order.map((group, gi) => (
        <div
          key={group}
          role="group"
          aria-label={group}
          className={gi > 0 ? "mt-[8px] pt-[8px] border-t border-[var(--shell-border-soft)]" : ""}
        >
          {groups[group].map((m) => (
            <NavItem
              key={m.slug}
              module={m}
              active={activeSlug === m.slug}
              onNavigate={onNavigate}
              collapsed={collapsed}
            />
          ))}
        </div>
      ))}
    </nav>
  );
}

function NavItem({
  module,
  active,
  onNavigate,
  collapsed = false,
}: {
  module: ModuleDef;
  active: boolean;
  onNavigate?: () => void;
  collapsed?: boolean;
}) {
  const Icon = module.icon;
  return (
    <Link
      href={module.href}
      onClick={onNavigate}
      aria-current={active ? "page" : undefined}
      title={collapsed ? module.label + (module.badge ? ` · ${module.badge}` : "") : undefined}
      className={`flex items-center min-h-[38px] rounded-md text-[14px] tracking-[-0.14px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
        collapsed ? "justify-center px-0 py-[9px] mb-[2px]" : "gap-2 px-[10px] py-[9px]"
      } ${
        active
          ? "bg-muted text-foreground font-medium"
          : "text-[var(--shell-muted-fg)] hover:bg-[var(--shell-surface-1)] hover:text-foreground"
      }`}
    >
      <Icon className="size-[14px] shrink-0" aria-hidden="true" />
      {!collapsed && <span className="flex-1 truncate">{module.label}</span>}
      {!collapsed && module.badge && (
        <span className="shrink-0 rounded-full border border-border bg-[var(--shell-surface-1)] px-[6px] py-[1px] text-[9px] font-medium uppercase tracking-[0.4px] text-[var(--shell-muted-fg)] tabular-nums">
          {module.badge}
        </span>
      )}
    </Link>
  );
}

function UserBlock({
  user,
  onSignOut,
  userMenu,
  collapsed = false,
}: {
  user: AppShellProps["user"];
  onSignOut?: () => void;
  userMenu?: ReactNode;
  collapsed?: boolean;
}) {
  if (!user) return null;
  const name = user.name.trim() || user.email?.split("@")[0] || "—";

  if (collapsed) {
    // Icon rail: avatar only; full name/label surface via the title tooltip.
    return (
      <div className="mt-auto border-t border-border p-[10px_8px] flex justify-center">
        <div
          title={user.label ? `${name} · ${user.label}` : name}
          className="w-[28px] h-[28px] rounded-full bg-accent border border-border flex items-center justify-center text-[10px] font-semibold text-foreground uppercase"
        >
          {initials(name)}
        </div>
      </div>
    );
  }

  return (
    <div className="mt-auto border-t border-border p-[10px_8px]">
      <div className="flex items-center gap-[9px] px-[8px] py-[7px] rounded-md">
        <div className="w-[28px] h-[28px] rounded-full bg-accent border border-border flex items-center justify-center text-[10px] font-semibold text-foreground shrink-0 uppercase">
          {initials(name)}
        </div>
        <div className="min-w-0 flex-1">
          <div className="text-[12px] font-medium text-foreground tracking-[-0.12px] leading-tight truncate">
            {name}
          </div>
          {user.label && (
            <div className="text-[11px] text-[var(--shell-muted-fg)] leading-tight">{user.label}</div>
          )}
        </div>
        {userMenu ??
          (onSignOut && (
            <button
              type="button"
              onClick={onSignOut}
              title="Sign out"
              aria-label="Sign out"
              className="shrink-0 rounded-md p-[6px] text-[var(--shell-muted-fg)] hover:text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <LogOut className="size-[15px]" aria-hidden="true" />
            </button>
          ))}
      </div>
    </div>
  );
}

function initials(name: string): string {
  const parts = name.split(/[\s@.]+/).filter(Boolean);
  if (parts.length === 0) return "—";
  if (parts.length === 1) return parts[0].slice(0, 2);
  return (parts[0][0] ?? "") + (parts[1][0] ?? "");
}

Dependencies

  • lucide-react
  • @bottega/tokens