App Shell
The frame of an app page: a sidebar of destinations, a top bar, and the page under it. On a phone the sidebar folds into a menu button that opens the same nav in a drawer.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/app-shell.jsonUsage
import { AppShell } from "@/components/ui/app-shell"Examples
Default
A vertical Nav in nav, the top bar's contents in header, the page as children. Below md the sidebar hides and a menu button opens the same Nav in a drawer, which closes when a link is followed.
DashboardMD
Dashboard
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| navAppShell | ReactNode | - | The destinations, a <Nav orientation="vertical">. Shown in the sidebar from md up and in the drawer below it. |
| brandAppShell | ReactNode | - | The app's logo or name, at the top of the sidebar and the drawer. |
| headerAppShell | ReactNode | - | The top bar's contents, laid out as a row with the far end free for actions. |
| childrenAppShell | ReactNode | - | The page, usually a <main>, under the top bar. |
navAppShellReactNodeThe destinations, a <Nav orientation="vertical">. Shown in the sidebar from md up and in the drawer below it. |
| brandAppShellReactNodeThe app's logo or name, at the top of the sidebar and the drawer. |
| headerAppShellReactNodeThe top bar's contents, laid out as a row with the far end free for actions. |
childrenAppShellReactNodeThe page, usually a <main>, under the top bar. |
"use client"
import * as React from "react"
import { IconMenu2 as Menu } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { Drawer, DrawerContent } from "@/registry/ui/drawer"
// @use-when the frame of an app page that is operated: a sidebar of
// destinations on the left, a top bar over the work, the page under it. On a
// phone the sidebar folds into a menu button in the top bar that opens the
// same nav in a drawer. Not SiteHeader, which is a marketing or docs site's
// bar, and not SiteNav, which is the phone menu for that bar.
function AppShell({
brand,
nav,
header,
className,
children,
...props
}: React.ComponentProps<"div"> & {
/** The app's logo or name, at the top of the sidebar and of the phone
* drawer, so the drawer still says whose app it is once it covers the page. */
brand?: React.ReactNode
/** The destinations: a `<Nav orientation="vertical">`. It renders in the
* sidebar from `md` up and in the drawer below it. */
nav: React.ReactNode
/** The top bar's contents, laid out as a row with the far end free for
* actions: a breadcrumb, then search, refresh, the account menu. */
header?: React.ReactNode
}) {
const [open, setOpen] = React.useState(false)
return (
<div
data-slot="app-shell"
className={cn("flex min-h-dvh bg-background", className)}
{...props}
>
{/* Sticky at the screen's full height, so the sidebar and its edge stay
put however long the page runs. */}
<aside
data-slot="app-shell-sidebar"
className="sticky top-0 hidden h-dvh w-56 shrink-0 flex-col gap-(--spacing-rhythm-cluster) overflow-y-auto border-r border-border p-4 md:flex"
>
{brand ? (
<div className="flex h-8 items-center px-3">{brand}</div>
) : null}
{nav}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header
data-slot="app-shell-header"
className="flex h-(--spacing-site-header) items-center gap-2 border-b border-border px-chrome-gutter"
>
<Button
variant="ghost"
size="icon"
className="-ml-2 md:hidden"
aria-label="Open navigation"
onClick={() => setOpen(true)}
iconStart={<Menu />}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-4">
{header}
</div>
</header>
{children}
</div>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent
side="left"
aria-label="Navigation"
// SiteNav's panel recipe: the drawer's own corner close stays, and
// the brand row reserves that corner with `pr-12`.
className="w-72 gap-(--spacing-rhythm-cluster) px-2 pt-3 pb-2"
>
{/* Following a link closes the drawer, the way a page change would
on a real navigation. */}
<div
className="flex flex-col gap-(--spacing-rhythm-cluster)"
onClickCapture={(event) => {
if ((event.target as Element).closest("a")) setOpen(false)
}}
>
{brand ? (
<div className="flex min-h-11 items-center px-3 pr-12">{brand}</div>
) : null}
{nav}
</div>
</DrawerContent>
</Drawer>
</div>
)
}
export { AppShell }