Layout
App shellPro
The frame of an app: navigation, top bar and the scrolling page. Four layouts share one nav model: collapsible sidebar, a rail that peeks open on hover, a double sidebar and top navigation. Phones get a sheet, and the collapsed state is remembered.
npx prodular-ui add app-shellPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Four layouts, one nav
Switch layouts above the frame. The same nav sections, workspace switcher, user menu and breadcrumb fill each one. Narrow the window to see the phone sheet.
"use client"
import * as React from "react"import { BellIcon, ChartNoAxesColumnIcon, CircleHelpIcon, LayoutDashboardIcon, ListChecksIcon, LogOutIcon, PlusIcon, ReceiptTextIcon, SearchIcon, SettingsIcon, UserIcon, UsersRoundIcon,} from "lucide-react"
import { AiMarkGlyph } from "@/components/ui/ai-mark"import { AppShell, AppShellUser, type AppNavItem, type AppNavSection, type AppShellLayout } from "@/components/ui/app-shell"import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"import { Button } from "@/components/ui/button"import { DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"import { Kbd } from "@/components/ui/kbd"import { SidebarGroupAction } from "@/components/ui/sidebar"import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"import { WorkspaceSwitcher, type Workspace } from "@/components/ui/workspace-switcher"
const workspaces: Workspace[] = [ { id: "halcyon", name: "Halcyon Labs", organization: "Halcyon", meta: "Scale plan, 42 members" }, { id: "halcyon-eu", name: "Halcyon Europe", organization: "Halcyon", meta: "Scale plan, 9 members" }, { id: "northgate", name: "Northgate Studio", organization: "Northgate", meta: "Growth plan, 6 members" },]
function AnalystIcon({ className }: { className?: string }) { return <AiMarkGlyph className={className} />}
const nav: AppNavSection[] = [ { id: "main", items: [ { id: "overview", label: "Overview", icon: LayoutDashboardIcon, description: "Cash, receivables and what needs you" }, { id: "analyst", label: "Analyst", icon: AnalystIcon, description: "Ask about your numbers" }, { id: "invoices", label: "Invoices", icon: ReceiptTextIcon, badge: 18, description: "Bill customers and get paid", items: [ { id: "invoices-all", label: "All invoices" }, { id: "invoices-drafts", label: "Drafts", badge: 3 }, { id: "invoices-recurring", label: "Recurring" }, { id: "invoices-credits", label: "Credit notes" }, ], }, { id: "customers", label: "Customers", icon: UsersRoundIcon, description: "Accounts, contacts and health", items: [ { id: "customers-all", label: "All customers" }, { id: "customers-segments", label: "Segments" }, { id: "customers-health", label: "Health" }, ], }, { id: "approvals", label: "Approvals", icon: ListChecksIcon, badge: 11, description: "Spend waiting for a decision" }, { id: "reports", label: "Reports", icon: ChartNoAxesColumnIcon, description: "Revenue, cash flow and spend", items: [ { id: "reports-revenue", label: "Revenue" }, { id: "reports-cash", label: "Cash flow" }, { id: "reports-spend", label: "Spend" }, ], }, ], }, { id: "favorites", label: "Favorites", action: ( <SidebarGroupAction aria-label="Add favorite"> <PlusIcon /> </SidebarGroupAction> ), items: [ { id: "fav-overdue", label: "Overdue over $10k", icon: Dot("var(--chart-1)") }, { id: "fav-kestrel", label: "Kestrel Health", icon: Dot("var(--chart-2)") }, ], },]
function Dot(color: string) { return function FavoriteDot({ className }: { className?: string }) { return ( <span className={className} style={{ display: "flex", alignItems: "center", justifyContent: "center" }}> <span style={{ backgroundColor: color }} className="size-2 rounded-full" /> </span> ) }}
const footerNav: AppNavItem[] = [ { id: "help", label: "Help and docs", icon: CircleHelpIcon }, { id: "settings", label: "Settings", icon: SettingsIcon },]
const labels: Record<string, string> = Object.fromEntries( [...nav.flatMap((s) => s.items.flatMap((i) => [i, ...(i.items ?? [])])), ...footerNav].map((i) => [i.id, i.label]))const parentOf = (id: string) => nav.flatMap((s) => s.items).find((i) => i.items?.some((c) => c.id === id))
export default function AppShellLayouts() { const [layout, setLayout] = React.useState<AppShellLayout>("sidebar") const [active, setActive] = React.useState("invoices-all") const [workspace, setWorkspace] = React.useState("halcyon") const parent = parentOf(active)
return ( <div className="flex w-full flex-col gap-4"> <ToggleGroup variant="outline" size="sm" spacing={0} value={[layout]} onValueChange={(v) => v.length && setLayout(v[0] as AppShellLayout)} aria-label="Layout" className="self-center" > <ToggleGroupItem value="sidebar">Sidebar</ToggleGroupItem> <ToggleGroupItem value="rail">Rail</ToggleGroupItem> <ToggleGroupItem value="double">Double sidebar</ToggleGroupItem> <ToggleGroupItem value="topnav">Top navigation</ToggleGroupItem> </ToggleGroup> <div className="h-[600px] w-full overflow-hidden rounded-xl border shadow-xs"> <AppShell contained layout={layout} nav={nav} footerNav={footerNav} activeId={active} onNavigate={(item, e) => { e.preventDefault() setActive(item.items?.[0]?.id ?? item.id) }} brand={<WorkspaceSwitcher workspaces={workspaces} value={workspace} onValueChange={setWorkspace} recent={["northgate"]} onCreate={() => {}} />} user={ <AppShellUser name="Maya Okafor" email="maya@halcyonlabs.com" initials="MO"> <DropdownMenuItem> <UserIcon /> Profile </DropdownMenuItem> <DropdownMenuItem> <SettingsIcon /> Preferences </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive"> <LogOutIcon /> Log out </DropdownMenuItem> </AppShellUser> } breadcrumb={ <Breadcrumb> <BreadcrumbList className="flex-nowrap"> {parent && ( <> <BreadcrumbItem className="hidden sm:inline-flex"> <BreadcrumbLink href="#">{parent.label}</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator className="hidden sm:inline-flex" /> </> )} <BreadcrumbItem> <BreadcrumbPage>{labels[active]}</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> } actions={ <> <Button variant="outline" size="sm" className="hidden gap-2 text-muted-foreground lg:inline-flex"> <SearchIcon data-icon="inline-start" /> Search <Kbd>⌘K</Kbd> </Button> <Button variant="ghost" size="icon-sm" aria-label="Notifications, 3 unread" className="relative text-muted-foreground"> <BellIcon /> <span aria-hidden="true" className="absolute top-1.5 right-1.5 size-1.5 rounded-full bg-primary ring-2 ring-background" /> </Button> </> } > <div className="flex flex-col gap-8 px-6 py-8 md:px-8"> <div className="flex flex-col gap-1"> <h1 className="text-xl font-semibold tracking-tight">{labels[active]}</h1> <p className="text-sm text-muted-foreground"> {layout === "rail" ? "Hover the rail to peek at the full navigation, or pin it open." : layout === "double" ? "Modules on the left, their pages beside them. The page list collapses from the top bar." : layout === "topnav" ? "Modules across the header, their pages in the row below." : "Collapse the sidebar to icons from the top bar or with Cmd+B."} </p> </div> <dl className="grid grid-cols-2 divide-x rounded-xl border bg-card md:grid-cols-4"> {[ ["Outstanding", "$412,800"], ["Overdue", "$61,900"], ["Paid this month", "$238,450"], ["Days to pay", "31"], ].map(([k, v]) => ( <div key={k} className="flex flex-col gap-1 p-4"> <dt className="text-[13px] text-muted-foreground">{k}</dt> <dd className="text-lg font-semibold tabular-nums">{v}</dd> </div> ))} </dl> </div> </AppShell> </div> </div> )}Anatomy
<AppShell layout="sidebar" nav={nav} activeId={pathname} linkComponent={Link} brand={<WorkspaceSwitcher ... />} user={<AppShellUser name="Maya Okafor" initials="MO">...</AppShellUser>} breadcrumb={<Breadcrumb>...</Breadcrumb>} storageKey="app"> {page}</AppShell>Guidance
Use it when
- The outer frame of a SaaS or AI app, once, around every signed-in page.
- sidebar for most apps; rail when the page needs the width, like editors and boards; double for products with modules that each have several pages; topnav for apps with five or fewer areas.
- brand for a WorkspaceSwitcher or a logo, user for AppShellUser, actions for search, notifications and one primary action.
Avoid
- Nesting shells. Settings and detail pages go inside the page, with Settings nav or Master detail.
- More than about nine items in a section. Group them, or move rare pages to a module in the double layout.
- Rebuilding the nav per page. Keep one nav model and change activeId.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Describe navigation once as AppNavSection[] with id, label, icon, href, badge and items for child pages, and pass the current page id as activeId.
- For client routing pass linkComponent={Link} from next/link. onNavigate receives every click; call event.preventDefault() only when you route yourself.
- Set storageKey (for example "app") so the collapsed sidebar and the double sidebar's page list survive reloads.
- Use contained when the shell sits inside another layout or a preview; otherwise it takes the full viewport height.
- Put WorkspaceSwitcher in brand and AppShellUser in user. Both size themselves for the expanded sidebar, the collapsed rail and the top navigation.
- Cmd or Ctrl+B toggles the sidebar. Every icon-only button has a tooltip and an accessible name; do not remove labels from nav items.
- Use fill with a Workspace or a full-bleed map or canvas, so the page area does not scroll.
API
AppShell
| Prop | Type | Default | Description |
|---|---|---|---|
| layout | "sidebar" | "rail" | "double" | "topnav" | "sidebar" | Where the navigation goes. |
| nav | AppNavSection[] | Sections of { id, label, icon, href, badge, items, description }. | |
| footerNav | AppNavItem[] | Pinned to the bottom of the sidebar, like Help and Settings. | |
| activeId | string | The current page. Its parents open and highlight. | |
| onNavigate | (item, event) => void | Every nav click. | |
| linkComponent | ElementType | "a" | Renders links, for example Next.js Link. |
| brand / user / breadcrumb / actions | ReactNode | Slots for the workspace, the account, the page path and top bar actions. | |
| storageKey | string | Remembers collapsed states in localStorage. | |
| defaultCollapsed | boolean | true for rail | Starts with the nav as icons. |
| contained | boolean | false | Fills the parent instead of the viewport. |
| fill | boolean | false | The page area fills the space under the top bar and does not scroll, for a Workspace or a full-bleed canvas. |
AppShellUser
| Prop | Type | Default | Description |
|---|---|---|---|
| name / email / initials / image | string | The signed-in person. Put DropdownMenuItems inside for the menu. |