Search

Jump to any page.

Navigation

App Shell

Sidebar, floating inset panel and top bar with automatic breadcrumbs. The frame for every Paragon product.

Open
pnpm dlx shadcn@latest add @paragon/app-shell
Brings in@paragon/breadcrumb@paragon/collapsible@paragon/separator@paragon/sidebar@paragon/tooltipcn
registry/paragon/blocks/app-shell/app-shell.tsx
"use client"

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

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/registry/paragon/ui/breadcrumb"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/registry/paragon/ui/collapsible"
import { Separator } from "@/registry/paragon/ui/separator"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarMenuToggle,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/registry/paragon/ui/sidebar"
import { TooltipProvider } from "@/registry/paragon/ui/tooltip"

export type AppShellNavItem = {
  title: string
  href: string
  icon?: React.ComponentType<{ className?: string }>
  badge?: React.ReactNode
  /** Overrides the path match, for anchors such as the section in view. */
  active?: boolean
  /** Nested pages, drawn as a trace under the item. One level deep. */
  items?: AppShellNavItem[]
}

export type AppShellNavGroup = {
  label?: string
  items: AppShellNavItem[]
}

function isActive(pathname: string, item: AppShellNavItem) {
  if (item.active !== undefined) return item.active
  return pathname === item.href || (item.href !== "/" && pathname.startsWith(`${item.href}/`))
}

/** The deepest match is the current page; its parent only holds it. */
function resolve(pathname: string, item: AppShellNavItem) {
  const child = item.items?.find((candidate) => isActive(pathname, candidate))
  return { child, self: !child && isActive(pathname, item) }
}

function findTrail(nav: AppShellNavGroup[], pathname: string) {
  for (const group of nav) {
    for (const item of group.items) {
      const { child, self } = resolve(pathname, item)
      if (child || self) return { group, item, child }
    }
  }
  return null
}

/**
 * The frame for every Paragon product: a sidebar on the frame colour, the
 * page in a floating inset panel that scrolls on its own, and a slim top bar
 * with breadcrumbs and actions. Collapses to icons with ⌘B; becomes a sheet
 * on mobile.
 */
export function AppShell({
  brand,
  nav,
  footer,
  breadcrumbs,
  actions,
  collapsible = "icon",
  defaultOpen = true,
  className,
  children,
}: {
  /** Sidebar header: a wordmark, product name or workspace switcher. */
  brand?: React.ReactNode
  nav: AppShellNavGroup[]
  /** Sidebar footer: account, settings or theme controls. */
  footer?: React.ReactNode
  /** Replaces the breadcrumbs derived from `nav`. */
  breadcrumbs?: React.ReactNode
  /** Right side of the top bar. */
  actions?: React.ReactNode
  collapsible?: "icon" | "offcanvas" | "none"
  defaultOpen?: boolean
  className?: string
  children: React.ReactNode
}) {
  const pathname = usePathname()

  return (
    <TooltipProvider>
      <SidebarProvider defaultOpen={defaultOpen} className={className}>
        <CloseOnNavigate pathname={pathname} />
        <Sidebar variant="inset" collapsible={collapsible}>
          {brand ? <SidebarHeader>{brand}</SidebarHeader> : null}
          <SidebarContent>
            {nav.map((group, index) => (
              <SidebarGroup key={group.label ?? index}>
                {group.label ? <SidebarGroupLabel>{group.label}</SidebarGroupLabel> : null}
                <SidebarMenu>
                  {group.items.map((item) => (
                    <AppShellMenuItem key={item.href} item={item} pathname={pathname} />
                  ))}
                </SidebarMenu>
              </SidebarGroup>
            ))}
          </SidebarContent>
          {footer ? <SidebarFooter>{footer}</SidebarFooter> : null}
          <SidebarRail />
        </Sidebar>
        <SidebarInset>
          <header
            data-slot="app-shell-header"
            className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-border/70 bg-background/80 px-3 backdrop-blur-md md:px-4"
          >
            <SidebarTrigger className="-ms-1" />
            <Separator orientation="vertical" className="mx-1 data-vertical:h-4 data-vertical:self-center" />
            <div className="min-w-0 flex-1">{breadcrumbs ?? <AppShellBreadcrumbs nav={nav} />}</div>
            {actions ? <div className="flex items-center gap-1.5">{actions}</div> : null}
          </header>
          <div data-slot="app-shell-content" className={cn("flex-1")}>
            {children}
          </div>
        </SidebarInset>
      </SidebarProvider>
    </TooltipProvider>
  )
}

/** Group › page, derived from the nav and the current path. */
export function AppShellBreadcrumbs({ nav }: { nav: AppShellNavGroup[] }) {
  const pathname = usePathname()
  const trail = findTrail(nav, pathname)
  if (!trail) return null

  return (
    <Breadcrumb>
      <BreadcrumbList className="flex-nowrap">
        {trail.group.label ? (
          <>
            <BreadcrumbItem className="hidden sm:inline-flex">{trail.group.label}</BreadcrumbItem>
            <BreadcrumbSeparator className="hidden sm:block" />
          </>
        ) : null}
        {trail.child ? (
          <>
            <BreadcrumbItem className="hidden sm:inline-flex">
              <BreadcrumbLink render={<Link href={trail.item.href} />}>{trail.item.title}</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator className="hidden sm:block" />
          </>
        ) : null}
        <BreadcrumbItem className="min-w-0">
          <BreadcrumbPage className="truncate">{(trail.child ?? trail.item).title}</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}

function AppShellMenuItem({ item, pathname }: { item: AppShellNavItem; pathname: string }) {
  const Icon = item.icon
  const { child: current, self } = resolve(pathname, item)
  const holds = !!current

  // Opens on the way in to a nested page; after that, the reader decides.
  const [open, setOpen] = React.useState(holds || self)
  const [held, setHeld] = React.useState(holds)
  if (holds !== held) {
    setHeld(holds)
    if (holds) setOpen(true)
  }

  const button = (
    <SidebarMenuButton isActive={self} tooltip={item.title} render={<Link href={item.href} />}>
      {Icon ? <Icon /> : null}
      <span>{item.title}</span>
    </SidebarMenuButton>
  )
  const badge = item.badge != null ? <SidebarMenuBadge>{item.badge}</SidebarMenuBadge> : null

  if (!item.items?.length) {
    return (
      <SidebarMenuItem>
        {button}
        {badge}
      </SidebarMenuItem>
    )
  }

  return (
    <Collapsible open={open} onOpenChange={setOpen} render={<SidebarMenuItem />}>
      {button}
      <SidebarMenuToggle aria-label={`${item.title} pages`} render={<CollapsibleTrigger />} />
      {badge}
      <CollapsibleContent className="group-data-[collapsible=icon]:hidden">
        <SidebarMenuSub>
          {item.items.map((child) => (
            <SidebarMenuSubItem key={child.href}>
              <SidebarMenuSubButton
                isActive={child === current}
                aria-current={child === current ? (child.active === undefined ? "page" : "location") : undefined}
                render={<Link href={child.href} />}
              >
                <span>{child.title}</span>
              </SidebarMenuSubButton>
            </SidebarMenuSubItem>
          ))}
        </SidebarMenuSub>
      </CollapsibleContent>
    </Collapsible>
  )
}

/** On mobile the sidebar is a sheet; close it once a link has navigated. */
function CloseOnNavigate({ pathname }: { pathname: string }) {
  const { setOpenMobile } = useSidebar()
  React.useEffect(() => {
    setOpenMobile(false)
  }, [pathname, setOpenMobile])
  return null
}