Skip to contentVibraUI
Navigation & layout

App shell

The frame every page renders in: a NavConfig-driven sidebar, breadcrumbs, command palette, notifications, theme, and account menu.

A client component for Next.js App Router: it reads usePathname() to work out where you are and useRouter() to navigate from the command palette, and its sidebar links are next/link. The rail sits at the inline start — the left of a left-to-right page, the right of a right-to-left one, on the desktop and as the phone's sheet — and the shell's own parts are drawn on logical sides; pass dir="rtl" to decide it on the server as well, or the shell reads the page's direction once it is mounted. Page content stays server-rendered — it arrives as children and the shell never re-renders it. The shell fills its container, so it is min-h-svh by default and a caller that boxes it replaces those height classes through className. Boxing it takes two more classes, because the sidebar is position:fixed on desktop: contain-paint, which makes the shell root the containing block the sidebar is fixed to, and [&_[data-slot=app-sidebar]]:h-full, which overrides the h-svh the sidebar sets — the app-shell-demo example shows the whole incantation. It owns the single <main> (from DashboardShell) and never renders an h1: the page does, through PageHeader. The active item is findNavItem(nav, activeHref ?? pathname) — longest matching href, exact items excluded from prefix matching — and its link carries aria-current="page"; activeHref is the route the page says it lives at, which wins over the URL the browser is showing, so a page rendered inside a preview frame still marks its own item and starts its trail there; an item with nested children renders as a disclosure button, which has no link of its own and so carries data-active instead. A brand whose href is the route you are already on drops its link, which is what keeps aria-current unique when a brand and a nav item share a route. Every other link — sidebar item, crumb, notification row — goes through next/link, so none of them costs a document load. Breadcrumbs default to breadcrumbsFor(nav, activeHref ?? pathname) with the page's own crumb dropped — the page writes its name once, in its PageHeader's h1 — so the default trail ends on the parent, which stays a link; a one-crumb trail is never cut. Pass showLastCrumb to keep the page's crumb, or breadcrumbs to override the trail, e.g. to end on a record's own name; either way the trail then ends on the page you are on, rendered as a plain page rather than an anchor. The trail renders in the breadcrumb nav landmark (the upstream Breadcrumb primitive names it "breadcrumb"). ⌘K opens a palette listing flattenNav(nav) with each item's icon, searchable by title or route; the search field is read-only, carries aria-haspopup="dialog", and opens the same dialog on click or Enter; below the sm breakpoint the field gives way to an icon button named Search that opens it too. Notifications map onto NotificationCenter, which dates each row with RelativeTime; marking one read is local to the session, and a new notifications array resets that. Which workspace is current is likewise seeded by workspaces.current and then owned by the shell, because the props carry no change handler. defaultOpen seeds the sidebar rail the same way, and the rail writes its own state to a sidebar_state cookie: read that cookie in a server component and hand it back — const jar = await cookies() then defaultOpen={jar.get("sidebar_state")?.value !== "false"} — and a collapsed rail survives navigation with no flash. onSignOut gives the account menu its one action, which UserMenu labels Log out; without it the menu is the identity block alone. useAppShell() hands any client descendant { nav, user, workspace }, and throws outside a shell. Icons resolve through NAV_ICONS in app-shell/icons.ts, a Record<NavIconName, LucideIcon> that stops compiling if the union grows a name it has no entry for.

Install

npx shadcn@latest add @vibra/app-shell

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
navNavConfig—The sidebar's brand, groups, and footer links, and the source of the breadcrumbs and the palette.
user{ name: string; email: string; avatarUrl?: string; initials?: string }—Who is signed in; the account menu is named after them.
dir"ltr" | "rtl"—The shell's reading direction, written on its root; the rail sits at its inline start. Left off, the shell reads the page's direction once mounted — so a right-to-left app that renders on the server passes it, or the server's page draws the rail on the left until the browser takes over.
workspaces{ current: string; items: { id: string; name: string; initial: string; plan?: string }[] }—Puts a workspace switcher in the sidebar footer. current seeds the selection; the shell owns it from there.
notifications{ id: string; title: string; description?: string; at: Date; read?: boolean; href?: string }[][]Rows for the bell; at is dated with RelativeTime and drives the unread count.
actionsReact.ReactNode—Extra header actions rendered before the theme toggle.
onSignOut() => void—Adds the account menu's "Log out" item; without it the menu shows only the identity block.
density"comfortable" | "compact"—Lays the whole shell out at this rhythm — the sidebar and the header with the page — by writing data-density onto the shell root. Left off, it inherits whatever the document says, which is what the reader's own DensityToggle writes.
defaultOpenbooleantrueInitial sidebar state. Read the sidebar_state cookie in a server component and pass it, so a collapsed rail survives navigation.
activeHrefstringusePathname()The route this page lives at. The shell matches the nav and builds the trail from it, so a page framed under some other URL — a preview route, a gallery iframe — still highlights its own item. Blocks export ROUTE from their nav.ts and pass it.
breadcrumbs{ title: string; href: string }[]breadcrumbsFor(nav, activeHref ?? pathname) minus the page's own crumbOverrides the trail, e.g. to end on a record name the nav cannot know. An explicit trail is rendered exactly as given, last crumb included, and ends on a plain page.
showLastCrumbbooleanfalseKeeps the page's own name as the last crumb of the default trail. Off, because the page writes its name once, in its PageHeader's h1; a page with no PageHeader — an embedded view, a full-bleed canvas — turns it back on. A one-crumb trail is never cut, so the workspace crumb always shows.
nowDatethe machine clockWhat the bell dates notifications against. A page whose figures are fixed to a reference date passes that date, so the bell does not drift away from the numbers as the year turns.
childrenReact.ReactNode—The page, rendered inside the shell's single <main>.
classNamestring—Classes for the root. Height classes replace the defaults, so h-[560px] min-h-0 boxes the shell.
useAppShell() => { nav: NavConfig; user: AppShellUser; workspace?: AppShellWorkspace }—What the shell knows, for any client component under it. Throws outside a shell.
NAV_ICONSRecord<NavIconName, LucideIcon>—Every nav icon name, resolved to the lucide component that draws it.

Dependencies

Source

components/ui/app-shell/index.tsx
"use client"

import * as React from "react"
import { usePathname } from "next/navigation"

import { cn } from "@/lib/utils"
import { DashboardShell } from "@/components/ui/dashboard-shell"
import { type Notification } from "@/components/ui/notification-center"
import { breadcrumbsFor, findNavItem, type NavConfig } from "@/lib/nav-config"
import { useNavOverride } from "@/components/ui/app-shell/nav-override"

import { AppShellHeader } from "./header"
import { AppShellSidebar } from "./sidebar"

export type AppShellUser = { name: string; email: string; avatarUrl?: string; initials?: string }
export type AppShellWorkspace = { id: string; name: string; initial: string; plan?: string }
export type AppShellNotification = {
  id: string
  title: string
  description?: string
  at: Date
  read?: boolean
  href?: string
}

export type AppShellContextValue = {
  nav: NavConfig
  user: AppShellUser
  workspace?: AppShellWorkspace
}

const AppShellContext = React.createContext<AppShellContextValue | null>(null)

/** Who is signed in, where they are working, and the nav the shell was given. */
function useAppShell(): AppShellContextValue {
  const context = React.useContext(AppShellContext)
  if (!context) {
    throw new Error("useAppShell must be called inside an <AppShell>.")
  }
  return context
}

// A stable empty list: a fresh `[]` default on every render would look like a
// new set of notifications and throw away what the reader has just read.
const NO_NOTIFICATIONS: AppShellNotification[] = []

export type AppShellProps = React.ComponentProps<"div"> & {
  nav: NavConfig
  user: AppShellUser
  workspaces?: { current: string; items: AppShellWorkspace[] }
  notifications?: AppShellNotification[]
  /**
   * What "now" is when the bell dates a notification. Defaults to the machine
   * clock, which is right for a live app; a page whose figures are fixed to a
   * reference date passes that date, so the bell does not drift away from the
   * numbers on the page as the year turns.
   */
  now?: Date
  /** Extra header actions rendered before the theme toggle. */
  actions?: React.ReactNode
  /**
   * The route this page lives at. Defaults to usePathname(); pass it so a page
   * rendered under some other URL — a preview frame, a gallery route — still
   * highlights its own nav item and builds the trail from it.
   */
  activeHref?: string
  /** Override the breadcrumb trail; defaults to breadcrumbsFor(nav, activeHref ?? pathname). */
  breadcrumbs?: { title: string; href: string }[]
  /**
   * Keeps the page's own name as the last crumb. Off by default: the page
   * writes its name once, in its PageHeader's h1, and a bar that repeats it
   * eight pixels above is the same word twice. A page with no PageHeader —
   * an embedded view, a full-bleed canvas — turns it back on. An explicit
   * `breadcrumbs` trail is used exactly as given either way.
   */
  showLastCrumb?: boolean
  /** Renders the account menu's "Log out" item (upstream UserMenu label). */
  onSignOut?: () => void
  /** Initial sidebar state; read the `sidebar_state` cookie server-side to keep it across navigations. */
  defaultOpen?: boolean
  /**
   * The row rhythm this page is laid out at, written onto the shell root so it
   * covers the sidebar as well as the page. Left off, the shell inherits
   * whatever the document says — which is what the reader's own DensityToggle
   * writes — so pass it only when the page has an opinion of its own.
   */
  density?: "comfortable" | "compact"
  children: React.ReactNode
}

/** The reading direction an element sits in: the nearest dir attribute, else its computed direction. */
function directionOf(element: HTMLElement): "ltr" | "rtl" {
  const attribute = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase()
  if (attribute === "rtl" || attribute === "ltr") return attribute
  return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr"
}

/** The frame every page renders in: nav at the inline start, page identity on top, your page inside. */
function AppShell({
  className,
  nav: ownNav,
  user,
  workspaces,
  notifications = NO_NOTIFICATIONS,
  now,
  actions,
  activeHref,
  breadcrumbs,
  showLastCrumb = false,
  onSignOut,
  defaultOpen,
  density,
  children,
  dir,
  ...props
}: AppShellProps) {
  // The rail sits at the inline start: the left of a left-to-right page and
  // the right of a right-to-left one, on the desktop and as the phone's sheet.
  // A `dir` given to the shell decides it on the server too; otherwise the
  // page's direction is read once the shell is mounted, as Carousel reads it.
  const root = React.useRef<HTMLDivElement>(null)
  const [pageDirection, setPageDirection] = React.useState<"ltr" | "rtl">("ltr")
  React.useLayoutEffect(() => {
    if (root.current) setPageDirection(directionOf(root.current))
  }, [])
  const side = (dir === "rtl" || dir === "ltr" ? dir : pageDirection) === "rtl" ? "right" : "left"

  // A page framed inside a template wears the template's navigation, the way it
  // would once installed there; everywhere else this is null and the page's own
  // nav stands.
  const override = useNavOverride()
  const nav = override?.nav ?? ownNav
  const pathname = usePathname()
  // The route the page says it is at wins over the one the browser is showing:
  // the two differ whenever the page is framed somewhere else.
  const route = override?.activeHref ?? activeHref ?? pathname

  // Which workspace is current is a choice the reader makes here, seeded by
  // the caller — the props carry no change handler to lift it to. A caller
  // that moves `current` itself still wins: the seed is re-read whenever it
  // changes.
  const [workspaceId, setWorkspaceId] = React.useState(workspaces?.current)
  const [lastCurrent, setLastCurrent] = React.useState(workspaces?.current)
  if (workspaces?.current !== lastCurrent) {
    setLastCurrent(workspaces?.current)
    setWorkspaceId(workspaces?.current)
  }

  // Read state is the same bargain: marking one read is a local act, and a
  // fresh list from the caller replaces the whole record.
  // The palette is opened from two places — the field under the brand and
  // the phone's search icon in the bar — so the shell holds the state.
  const [paletteOpen, setPaletteOpen] = React.useState(false)
  const [readIds, setReadIds] = React.useState<string[]>([])
  const [lastNotifications, setLastNotifications] = React.useState(notifications)
  if (notifications !== lastNotifications) {
    setLastNotifications(notifications)
    setReadIds([])
  }

  const active = React.useMemo(() => findNavItem(nav, route), [nav, route])
  const trail = React.useMemo(() => {
    if (breadcrumbs) return breadcrumbs
    const full = breadcrumbsFor(nav, route)
    // Never below one crumb: the workspace is where the trail starts, and a
    // bar with nothing in it reads as a missing element rather than a quiet one.
    return showLastCrumb || full.length < 2 ? full : full.slice(0, -1)
  }, [breadcrumbs, nav, route, showLastCrumb])
  // A trail the shell cut short still ends somewhere you can go, so its last
  // crumb stays a link rather than becoming a page that is not this page.
  const trailEndsAtPage =
    breadcrumbs !== undefined || showLastCrumb || breadcrumbsFor(nav, route).length < 2

  const workspace = workspaces?.items.find((item) => item.id === workspaceId)
  const context = React.useMemo<AppShellContextValue>(
    () => ({ nav, user, workspace }),
    [nav, user, workspace]
  )

  const rows = React.useMemo<Notification[]>(
    () =>
      notifications.map((notification) => ({
        id: notification.id,
        title: notification.title,
        description: notification.description,
        // NotificationCenter dates every row with RelativeTime.
        time: notification.at,
        read: notification.read === true || readIds.includes(notification.id),
        href: notification.href,
      })),
    [notifications, readIds]
  )

  return (
    <AppShellContext.Provider value={context}>
      <div
        ref={root}
        data-slot="app-shell"
        data-density={density}
        dir={dir}
        // Full height by default, and no taller than its box when a caller
        // gives it one — the height classes here are the ones cn() lets a
        // caller's className replace.
        //
        // COUPLED TO registry/vibra/ui/dashboard-shell.tsx: the inset it
        // renders is a flex item beside the sidebar with the default
        // min-width:auto, so the header bar inside it — a trail plus a search
        // field and four controls — sets a floor the row cannot fall below,
        // and a shell narrower than that floor spills its right-hand controls
        // out of the frame instead of tightening. DashboardShell takes no
        // className for the inset, so the rule is written from here; move it
        // onto the inset itself if it ever grows one.
        className={cn(
          "flex min-h-svh w-full flex-col [&_[data-slot=sidebar-inset]]:min-w-0",
          className
        )}
        {...props}
      >
        <DashboardShell
          defaultOpen={defaultOpen}
          className="min-h-0 flex-1"
          contentClassName="min-h-0"
          sidebar={
            <AppShellSidebar
              side={side}
              nav={nav}
              activeHref={active?.href}
              onSearch={() => setPaletteOpen(true)}
              workspaces={workspaces?.items}
              workspaceId={workspaceId}
              onWorkspaceChange={setWorkspaceId}
            />
          }
          header={
            <AppShellHeader
              nav={nav}
              paletteOpen={paletteOpen}
              onPaletteOpenChange={setPaletteOpen}
              breadcrumbs={trail}
              user={user}
              notifications={rows}
              now={now}
              actions={actions}
              onSignOut={onSignOut}
              lastCrumbIsPage={trailEndsAtPage}
              onMarkRead={(id) =>
                setReadIds((current) => (current.includes(id) ? current : [...current, id]))
              }
              onMarkAllRead={() => setReadIds(notifications.map((item) => item.id))}
            />
          }
        >
          {children}
        </DashboardShell>
      </div>
    </AppShellContext.Provider>
  )
}

export { AppShell, AppShellContext, useAppShell }
components/ui/app-shell/sidebar.tsx
"use client"

import * as React from "react"
import Link from "next/link"

import {
  AppSidebar,
  type NavGroup as SidebarNavGroup,
  type NavItem as SidebarNavItem,
} from "@/components/ui/app-sidebar"
import { SearchInput } from "@/components/ui/search-input"
import { WorkspaceSwitcher } from "@/components/ui/workspace-switcher"
import { type NavConfig, type NavItem } from "@/lib/nav-config"

// Type-only, so nothing of index.tsx survives into this module at runtime and
// the two files never form an import cycle.
import type { AppShellWorkspace } from "./index"
import { SEARCH_HOTKEY } from "./header"
import { navIcon } from "./icons"
import { useNavOverride } from "./nav-override"

export type AppShellSidebarProps = {
  nav: NavConfig
  /** The href of the item findNavItem matched, or undefined when none did. */
  activeHref?: string
  /** Opens the command palette; the field under the brand is its affordance. */
  onSearch: () => void
  workspaces?: AppShellWorkspace[]
  workspaceId?: string
  onWorkspaceChange?: (id: string) => void
  /** The physical edge the rail sits on: the inline start, which the shell reads off the page. */
  side?: "left" | "right"
}

/** A NavConfig item, with its named icon resolved to the component that draws it. */
function toSidebarItem(item: NavItem): SidebarNavItem {
  return {
    title: item.title,
    href: item.href,
    icon: navIcon(item.icon),
    badge: item.badge,
    items: item.items?.map((child) => ({ title: child.title, href: child.href })),
  }
}

function toSidebarGroups(nav: NavConfig): SidebarNavGroup[] {
  return nav.groups.map((group) => ({
    label: group.label,
    items: group.items.map(toSidebarItem),
  }))
}

/** The shell's sidebar: the nav config as groups, and the workspace you are in. */
function AppShellSidebar({
  nav,
  activeHref,
  onSearch,
  workspaces,
  workspaceId,
  onWorkspaceChange,
  side = "left",
}: AppShellSidebarProps) {
  // A framed page's hrefs are not routes where it is shown — the frame rewrites
  // them on the way out — so prefetching them would only fetch 404s.
  const framed = useNavOverride() !== null
  const renderLink = React.useCallback(
    (href: string, props: React.ComponentProps<"a">) => (
      // The marker goes on the anchor rather than on the menu button, so a
      // reader hears "current page" on the thing that navigates. An item with
      // children renders as a disclosure button and has no anchor of its own;
      // its data-active still says which group the page is in.
      <Link
        href={href}
        prefetch={framed ? false : undefined}
        {...props}
        aria-current={href === activeHref ? "page" : undefined}
      />
    ),
    [activeHref, framed]
  )

  return (
    <AppSidebar
      side={side}
      brand={{
        name: nav.brand.name,
        description: nav.brand.caption,
        // The page you are on is not a link — the same rule the trail follows,
        // where the last crumb is a page rather than a link. It is also what
        // keeps aria-current="page" unique in the sidebar: a brand and a nav
        // item may point at the same route, and only the item should carry it.
        href: nav.brand.href === activeHref ? undefined : nav.brand.href,
        // Decorative: the wordmark beside it carries the name.
        logo: (
          <span aria-hidden="true" className="text-sm font-semibold">
            {nav.brand.initial}
          </span>
        ),
      }}
      groups={toSidebarGroups(nav)}
      secondary={nav.footer?.map(toSidebarItem)}
      search={
        // Read-only on purpose: the typing happens in the palette. The field
        // is the affordance — it carries the shortcut hint, and a click or a
        // press of Enter opens the same dialog the chord does.
        //
        // COUPLED TO registry/vibra/ui/search-input.tsx: the field's two
        // overlay spans (the leading icon, and the trailing keycap) sit above
        // the input. The keycap itself already ignores the mouse, but its span
        // does not, so a click on the ⌘K corner would land on nothing at all;
        // sending both spans out of the way lets every part of the field
        // reach the input's own handler.
        <SearchInput
          size="sm"
          readOnly
          shortcut={SEARCH_HOTKEY}
          // The palette binds the chord; the field only prints it, so a ⌘K
          // is answered once, by the palette, and never focuses this field
          // behind the dialog it opens.
          shortcutScope={null}
          aria-label="Search"
          aria-haspopup="dialog"
          placeholder="Search"
          className="[&>span]:pointer-events-none"
          onClick={onSearch}
          onKeyDown={(event) => {
            if (event.key !== "Enter") return
            event.preventDefault()
            onSearch()
          }}
        />
      }
      // AppSidebar lights the longest match, the rule findNavItem follows;
      // handing it the matched item's own href rather than the route makes the
      // two agree on `exact` items too, which AppSidebar's own items cannot
      // carry — and it still opens the collapsible group the match sits in.
      activePath={activeHref}
      renderLink={renderLink}
      footer={
        workspaces?.length && workspaceId && onWorkspaceChange ? (
          <WorkspaceSwitcher
            size="sm"
            // The rail collapses to an icon width but its footer content does
            // not, so the switcher clips itself down to its mark rather than
            // spilling the workspace name across the page beside it.
            className="overflow-hidden"
            workspaces={workspaces.map((workspace) => ({
              id: workspace.id,
              name: workspace.name,
              plan: workspace.plan,
              // The config carries the mark it wants; WorkspaceMark would
              // otherwise derive its own initials from the name.
              logo: workspace.initial,
            }))}
            activeId={workspaceId}
            onSelect={onWorkspaceChange}
          />
        ) : undefined
      }
    />
  )
}

export { AppShellSidebar }
components/ui/app-shell/header.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { SearchIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { CommandPalette, type CommandItem } from "@/components/ui/command-palette"
import { HeaderBar } from "@/components/ui/header-bar"
import {
  NotificationCenter,
  type Notification,
} from "@/components/ui/notification-center"
import { ThemeToggle } from "@/components/ui/theme-toggle"
import { UserMenu } from "@/components/ui/user-menu"
import { flattenNav, type NavConfig } from "@/lib/nav-config"

import { useNavOverride } from "./nav-override"

// Type-only, so nothing of index.tsx survives into this module at runtime and
// the two files never form an import cycle.
import type { AppShellUser } from "./index"
import { navIcon } from "./icons"

/** The chord that opens the palette from anywhere on the page; the sidebar's field prints it. */
export const SEARCH_HOTKEY = "mod+k"

export type AppShellHeaderProps = {
  nav: NavConfig
  /** The palette's open state — owned by the shell, because the sidebar's search field opens it too. */
  paletteOpen: boolean
  onPaletteOpenChange: (open: boolean) => void
  breadcrumbs: { title: string; href: string }[]
  user: AppShellUser
  /** Already merged with whatever the reader has marked read in this session. */
  notifications: Notification[]
  /** What "now" is when the bell dates a row; defaults to the machine clock. */
  now?: Date
  onMarkRead: (id: string) => void
  onMarkAllRead: () => void
  actions?: React.ReactNode
  /** Adds the account menu's "Log out" item. */
  onSignOut?: () => void
  /** False when the trail stops short of the page, because a PageHeader names it. */
  lastCrumbIsPage?: boolean
}

/** The bar above the page: where you are, what you can jump to, and who you are. */
function AppShellHeader({
  nav,
  paletteOpen,
  onPaletteOpenChange,
  breadcrumbs,
  user,
  notifications,
  now,
  onMarkRead,
  onMarkAllRead,
  actions,
  onSignOut,
  lastCrumbIsPage = true,
}: AppShellHeaderProps) {
  // Every link the bar renders — crumbs and notification rows alike — goes
  // through the router, so none of them costs a full document load; the sidebar
  // has its own, which also stamps the current page. A framed page's hrefs are
  // not routes where it is shown, so there they do not prefetch either.
  const override = useNavOverride()
  const framed = override !== null
  const renderLink = React.useCallback(
    (href: string, props: React.ComponentProps<"a">) => (
      <Link href={href} prefetch={framed ? false : undefined} {...props} />
    ),
    [framed]
  )
  const router = useRouter()
  // A palette pick is a router call, not a link, so nothing outside the page
  // can take it over the way it takes a click over: under an override the
  // override says where the route is shown, or the pick goes nowhere.
  const go = React.useCallback(
    (href: string) => {
      if (override?.navigate) override.navigate(href)
      else if (!framed) router.push(href)
    },
    [override, framed, router]
  )

  const commands = React.useMemo<CommandItem[]>(
    () =>
      flattenNav(nav).map((item) => {
        const Icon = navIcon(item.icon)
        return {
          id: item.href,
          label: item.title,
          icon: Icon ? <Icon /> : undefined,
          // cmdk matches rows by value, which is the label plus the keywords —
          // so the route both disambiguates two same-named items and becomes
          // something you can search by.
          keywords: [item.href],
          onSelect: () => go(item.href),
        }
      }),
    [nav, go]
  )

  return (
    <>
      <HeaderBar
        sticky
        lastCrumbIsPage={lastCrumbIsPage}
        breadcrumbs={breadcrumbs.map((crumb) => ({ label: crumb.title, href: crumb.href }))}
        renderLink={renderLink}
        search={
          // The search field lives under the brand in the sidebar; the bar
          // keeps one icon for the phone, where the sidebar is a sheet and the
          // field inside it is a tap further away than the dialog itself.
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            aria-label="Search"
            aria-haspopup="dialog"
            className="md:hidden"
            onClick={() => onPaletteOpenChange(true)}
          >
            <SearchIcon />
          </Button>
        }
        actions={
          <>
            {actions}
            <NotificationCenter
              notifications={notifications}
              now={now}
              onMarkRead={onMarkRead}
              onMarkAllRead={onMarkAllRead}
              renderLink={renderLink}
            />
            <ThemeToggle size="sm" />
            <UserMenu
              user={{
                name: user.name,
                email: user.email,
                src: user.avatarUrl,
                fallback: user.initials,
              }}
              onSignOut={onSignOut}
            />
          </>
        }
      />

      {/* Outside the bar: it is a dialog, and its own hotkey listener is what
          opens it from anywhere on the page. */}
      <CommandPalette
        open={paletteOpen}
        onOpenChange={onPaletteOpenChange}
        hotkey={SEARCH_HOTKEY}
        placeholder="Search pages…"
        emptyMessage="No page matches that."
        groups={[{ heading: "Go to", items: commands }]}
      />
    </>
  )
}

export { AppShellHeader }
components/ui/app-shell/icons.ts
import {
  ActivityIcon,
  AudioLinesIcon,
  BellIcon,
  BotIcon,
  BriefcaseIcon,
  BuildingIcon,
  CalendarIcon,
  ChartLineIcon,
  CoinsIcon,
  CreditCardIcon,
  FileTextIcon,
  FolderIcon,
  GitBranchIcon,
  GraduationCapIcon,
  HeartPulseIcon,
  HomeIcon,
  ImageIcon,
  InboxIcon,
  KeyIcon,
  KeyRoundIcon,
  LayoutDashboardIcon,
  LifeBuoyIcon,
  ListIcon,
  ListTodoIcon,
  MailIcon,
  MegaphoneIcon,
  MessageCircleIcon,
  MessageSquareHeartIcon,
  PackageIcon,
  PlugIcon,
  ReceiptIcon,
  ServerIcon,
  SettingsIcon,
  ShieldIcon,
  ShoppingCartIcon,
  SparklesIcon,
  StickyNoteIcon,
  TriangleAlertIcon,
  UserRoundIcon,
  UsersIcon,
  WalletIcon,
  WorkflowIcon,
  type LucideIcon,
} from "lucide-react"

import { type NavIconName } from "@/lib/nav-config"

/**
 * The one place a `NavIconName` becomes a drawable component. A NavConfig is
 * plain data — it has to survive a server module, a template manifest, and the
 * trip across the client boundary — so it names its icons instead of holding
 * them. The `Record<NavIconName, LucideIcon>` annotation is the guarantee: a
 * name added to the union stops this file compiling until it has an entry.
 */
const NAV_ICONS: Record<NavIconName, LucideIcon> = {
  "layout-dashboard": LayoutDashboardIcon,
  users: UsersIcon,
  "user-round": UserRoundIcon,
  "shopping-cart": ShoppingCartIcon,
  receipt: ReceiptIcon,
  "credit-card": CreditCardIcon,
  settings: SettingsIcon,
  bell: BellIcon,
  inbox: InboxIcon,
  calendar: CalendarIcon,
  activity: ActivityIcon,
  shield: ShieldIcon,
  key: KeyIcon,
  "key-round": KeyRoundIcon,
  plug: PlugIcon,
  "chart-line": ChartLineIcon,
  "file-text": FileTextIcon,
  "life-buoy": LifeBuoyIcon,
  server: ServerIcon,
  sparkles: SparklesIcon,
  package: PackageIcon,
  megaphone: MegaphoneIcon,
  "git-branch": GitBranchIcon,
  "alert-triangle": TriangleAlertIcon,
  list: ListIcon,
  home: HomeIcon,
  briefcase: BriefcaseIcon,
  building: BuildingIcon,
  "graduation-cap": GraduationCapIcon,
  "heart-pulse": HeartPulseIcon,
  coins: CoinsIcon,
  folder: FolderIcon,
  "sticky-note": StickyNoteIcon,
  "message-circle": MessageCircleIcon,
  "list-todo": ListTodoIcon,
  bot: BotIcon,
  image: ImageIcon,
  "audio-lines": AudioLinesIcon,
  wallet: WalletIcon,
  workflow: WorkflowIcon,
  mail: MailIcon,
  "message-square-heart": MessageSquareHeartIcon,
}

/** The component for a name, or undefined for an item that carries no icon. */
function navIcon(name?: NavIconName): LucideIcon | undefined {
  return name ? NAV_ICONS[name] : undefined
}

export { NAV_ICONS, navIcon }
components/ui/app-shell/nav-override.tsx
"use client"

import * as React from "react"

import { type NavConfig } from "@/lib/nav-config"

/**
 * The navigation a page wears when something outside it decides.
 *
 * A block ships with its own `nav.ts` because it installs as a route in your
 * app and has to stand up alone. Put the same block in a template and that is
 * no longer true: every page there shares the template's navigation, which is
 * why the generator rewrites each block's `nav.ts` into a re-export of
 * `app/nav.ts`.
 *
 * A preview cannot rewrite a file, so it says so at runtime instead. Wrap a
 * page in this and its shell takes the nav given here in place of the one the
 * page passed — the template browser frames a block this way, so the frame
 * shows the template's own sidebar rather than the block's, which is what the
 * reader would get if they installed it.
 *
 * `activeHref` travels with it because the two are one decision: a nav the page
 * did not choose does not contain the route the page thinks it is at. And
 * `navigate` travels with both: the nav's routes are not routes where the page
 * is shown, so when the shell moves to one itself — a command palette pick,
 * which is a router call rather than a link — it asks the override where that
 * route is shown instead of pushing an address that does not exist here.
 */
export type NavOverride = {
  nav: NavConfig
  activeHref?: string
  /** Moves the page to one of the nav's routes; the shell's own router when absent. */
  navigate?: (href: string) => void
}

const NavOverrideContext = React.createContext<NavOverride | null>(null)

export function NavOverrideProvider({
  value,
  children,
}: {
  value: NavOverride
  children: React.ReactNode
}) {
  const stable = React.useMemo(() => value, [value])
  return <NavOverrideContext.Provider value={stable}>{children}</NavOverrideContext.Provider>
}

/** The override in force, or null — which is the ordinary case. */
export function useNavOverride(): NavOverride | null {
  return React.useContext(NavOverrideContext)
}