Navigation
App Shell
Sidebar, floating inset panel and top bar with automatic breadcrumbs. The frame for every Paragon product.
pnpm dlx shadcn@latest add @paragon/app-shellBrings 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
}