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-shellUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 | — | |
| userMenu | ReactNode | — | Replaces the default sign-out button in the user block when provided. |
| topbarActions | ReactNode | — | Right side of the topbar (before the page's own `right` slot). |
| palette | ReactNode | — | Slot for a command palette trigger (e.g. @bottega /command-palette). |
| activePath | string | — | Override the active path (defaults to usePathname()) — used by previews. |
| className | string | — | Extra 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