App shell
The page around an app's screens: a header frame, the sidebar, the page and an optional aside, one gutter apart. It lays itself out by its own width, not the window's, so it works in a pane too. Wide, everything sits side by side. Narrower, the aside opens as a sheet. Narrowest, the sidebar does too.
Loading the live example…
"use client"
import { useRef, useState } from "react"
import { AppShell } from "@/components/keyline/ui/app-shell"
import { Breadcrumb } from "@/components/keyline/ui/breadcrumb"
import { Button } from "@/components/keyline/ui/button"
import { Inspector, InspectorActions, InspectorBody, InspectorFacts, InspectorHeader } from "@/components/keyline/ui/inspector"
import { Item, ItemContent, ItemList, ItemMedia } from "@/components/keyline/ui/item"
import { Nav } from "@/components/keyline/ui/nav"
import { Status } from "@/components/keyline/ui/status"
const VIEWS = { orders: "Orders", clients: "Clients", warehouse: "Warehouse" } as const
type View = keyof typeof VIEWS
const ORDERS = [
{ id: "DM-4107", client: "Lackawanna County", cases: 120, rep: "Dwight Schrute" },
{ id: "DM-4108", client: "Blue Cross of Pennsylvania", cases: 60, rep: "Jim Halpert" },
{ id: "DM-4109", client: "Vance Refrigeration", cases: 12, rep: "Phyllis Vance" },
]
export function OfficeShell() {
const [view, setView] = useState<View>("orders")
const [open, setOpen] = useState<string | null>(null)
const opener = useRef<HTMLElement | null>(null)
const order = ORDERS.find((o) => o.id === open)
return (
<AppShell
// a low breakpoint keeps the sidebar in a preview this narrow
narrowAt={400}
mainProps={{ className: "min-h-[300px]" }}
navSize={180}
navLabel="Scranton branch"
nav={
<Nav
layout="cell"
label="Scranton branch"
value={view}
onValueChange={(v) => setView(v as View)}
items={Object.entries(VIEWS).map(([value, label]) => ({ value, label }))}
/>
}
header={<Breadcrumb layout="cell" items={[{ label: "Scranton", render: <a href="?view=orders" onClick={(e) => (e.preventDefault(), setView("orders"))} /> }, { label: VIEWS[view] }]} />}
actions={<Button layout="cell" variant="primary">New order</Button>}
aside={
order ? (
<Inspector>
<InspectorHeader code={order.id} title={order.client} status={<Status kind="normal">Out for delivery</Status>} onClose={() => setOpen(null)} />
<InspectorBody>
<InspectorFacts
items={[
{ label: "Cases", value: order.cases },
{ label: "Rep", value: order.rep },
]}
/>
</InspectorBody>
<InspectorActions>
<Button layout="cell" onClick={() => setOpen(null)}>
Close
</Button>
</InspectorActions>
</Inspector>
) : null
}
asideLabel="Order"
asideOpen={!!order}
onAsideOpenChange={(o) => (o ? null : setOpen(null))}
asideFinalFocus={opener}
>
{view === "orders" ? (
<ItemList aria-label="Open orders">
{ORDERS.map((o) => (
<Item
key={o.id}
current={o.id === open}
render={<button type="button" />}
onClick={(e) => {
opener.current = e.currentTarget
setOpen(o.id)
}}
>
<ItemMedia>{o.id.slice(3)}</ItemMedia>
<ItemContent title={o.client} description={o.rep} />
</Item>
))}
</ItemList>
) : (
<p className="m-0 text-[14px] text-kl-ink-2">{view === "clients" ? "64 active clients." : "88 pallets on the floor."}</p>
)}
</AppShell>
)
}Installation
npx shadcn@latest add @keyline-ui/app-shellRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import { AppShell } from "@/components/keyline/ui/app-shell"<AppShell
navLabel="Scranton branch"
nav={<Nav layout="cell" label="Scranton branch" items={…} />}
header={<Breadcrumb layout="cell" items={…} />}
actions={<Button layout="cell" variant="primary">New order</Button>}
aside={<Inspector>…</Inspector>}
asideOpen={!!order}
onAsideOpenChange={…}
>
…the page
</AppShell>Props
| Prop | Type | Default | Description |
|---|---|---|---|
nav | ReactNode | Default — | The sidebar, usually Nav layout="cell". |
navLabel | string | Default — | Names the sidebar: the toggle says “Show” it, and its narrow sheet takes the name. |
navOpen · defaultNavOpen · onNavOpenChange | boolean | Default true | Whether the sidebar column shows on a wide layout. |
collapse | "hide" | "rail" | Default "hide" | What closing the sidebar does. rail keeps a column of icons. See Sidebar. |
header · actions | ReactNode | Default — | The header after the toggle (usually a Breadcrumb) and the cells at its end (a Button, or a Toolbar with overflow="menu"). |
aside · asideOpen · onAsideOpenChange | ReactNode · boolean | Default — | A panel at the end, such as an Inspector, shown while open. |
asideFinalFocus | ref | Default — | Where focus goes when the aside's sheet closes. Pass the control that opened it. |
narrowAt · asideInlineAt | number | Default 960 · narrowAt + 440 | Shell widths where the sidebar and the aside turn into sheets. |
navSize · asideSize | number | string | Default 15rem · 22rem | Column widths on a wide layout. |
stickyHeader | boolean | Default false | Keeps the header in view; the columns stay put while the page scrolls past. |
mainProps | props of <main> | Default — | For the page's main element. |
The example sets narrowAt low so the sidebar stays in a preview this size. Choose an order to open the aside: on this page's width it opens as a sheet. This site's docs pages are a Keyline AppShell.