an app page's frame: one bar, a sidebar on each hand, a slim foot, a layer
npx shadcn add @ag/shellAlready installed? Preview an update
npx shadcn add @ag/shell --dry-runYour installed source stays unchanged until you update it. How updates work
- Fixed: in glass the content stood on the picture behind the frame, unreadable. A glass shell is now one glass body behind all it holds (as a Panel's back layer, its corners the frame's; held to the view in a window shell), the bar, the foot and the sidebars its fronts.
- New: the frame of an app page.
Shell(the window scrolls it, orheightmakes it a box that scrolls itself;toTop),ShellBar(leftandrightsidebars, whose toggles are its first and last slots and never move;start,center,endbetween them),Sidebars from thesidebaritem,ShellMain,ShellFootandShellLayer(over the main content and the sidebars, between the bar and the foot; Esc closes it), placed by the shell whatever their order. The bar's and foot's heights are told as--shell-barand--shell-footon the document's root, which takes them asscroll-paddingtoo.useShell()giveslayer,bar,footandscroller. Plain or glass. - Fixed: a bar with no
centergave its start only a third of the room and drew a full start over its end. Without a centre the start takes all the room the end does not need; a side that still does not fit clips at its own edge. - Fixed: a window shell showed the browser's scrollbar the window's whole height beside the bar, and its gutter dented the bar's end. The shell now hides the document's own bar and draws scroll-thumb's thumb for the window, its track between the bar and the foot; the bar runs edge to edge.
- Fixed: the frame flickered without end with its left sidebar closed (kept closed across a reload): the closed sidebar shared the bar's line and stretched the bar, whose measured height set the sidebar's again. The bar and the foot are now as tall as what they hold, whatever their line.
also called app shell, layout, workspace
shelf foundations
uses key-router, material, scroll-thumb, sidebar
the demo above, verbatim
"use client"
import { Expand, X } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import {
isBoolean,
type Setting,
setting,
} from "@/registry/base-nova/lib/remembered-core"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import { Search } from "@/registry/base-nova/ui/search"
import {
Shell,
ShellBar,
ShellFoot,
ShellLayer,
ShellMain,
useShell,
} from "@/registry/base-nova/ui/shell"
import {
Sidebar,
type SidebarState,
useSidebar,
} from "@/registry/base-nova/ui/sidebar"
import { Status } from "@/registry/base-nova/ui/status"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// #region data
const PROJECTS = [
"field notes",
"harbour survey",
"kiln logs",
"night trains",
"orchard map",
"paper mill",
"quarry sounds",
"river gauges",
"salt flats",
"tide tables",
"upland huts",
"winter light",
]
const ENTRIES = Array.from({ length: 14 }, (_, i) => ({
title: `entry ${i + 1}`,
body: "The gauge at the weir read high all morning, then fell back by noon as the sluices upstream were opened. Two walkers came down the east bank; the ferry stayed tied up until the wind dropped.",
}))
// #endregion
// #region frame
/** The bar's controls, and others while the viewer is open (`useShell().layer`). */
function Bar({
left,
right,
onClose,
}: {
left: SidebarState
right: SidebarState
onClose: () => void
}) {
const shell = useShell()
const [view, setView] = React.useState("list")
const [query, setQuery] = React.useState("")
if (shell.layer)
return (
<ShellBar
left={left}
right={right}
start={<span className="font-medium text-sm">entry 3</span>}
end={
<Button size="sm" variant="ghost" icon={<X />} onClick={onClose}>
close
</Button>
}
/>
)
return (
<ShellBar
left={left}
right={right}
start={<span className="truncate font-medium text-sm">river gauges</span>}
center={
<ToggleGroup
aria-label="view"
size="sm"
value={view}
onValueChange={setView}
>
<ToggleGroupItem value="list">list</ToggleGroupItem>
<ToggleGroupItem value="board">board</ToggleGroupItem>
<ToggleGroupItem value="map">map</ToggleGroupItem>
</ToggleGroup>
}
end={
<Search
value={query}
onChange={setQuery}
label="search entries"
className="w-40"
/>
}
/>
)
}
/** The projects side, open or not, kept across reloads. */
const PROJECTS_OPEN = setting("shell-demo:projects", {
initial: true,
keep: "always",
parse: isBoolean,
})
function Frame({
remember,
className,
}: {
remember?: Setting<boolean>
className?: string
}) {
const projects = useSidebar({ side: "left", label: "projects", remember })
const details = useSidebar({ side: "right", label: "details" })
const [viewing, setViewing] = React.useState(false)
return (
<Shell height="32rem" toTop className={className}>
<Bar left={projects} right={details} onClose={() => setViewing(false)} />
<Sidebar sidebar={projects} width="12rem">
{PROJECTS.map((p) => (
<Button
key={p}
size="sm"
variant={p === "river gauges" ? "secondary" : "ghost"}
className="justify-start"
>
{p}
</Button>
))}
</Sidebar>
<ShellMain className="flex flex-col gap-4 p-4">
{ENTRIES.map((e, i) => (
<Panel
key={e.title}
title={e.title}
action={
i === 2 ? (
<Button
size="sm"
variant="outline"
icon={<Expand />}
onClick={() => setViewing(true)}
>
open
</Button>
) : undefined
}
>
<p className="text-current/70 text-sm">{e.body}</p>
</Panel>
))}
</ShellMain>
<Sidebar
sidebar={details}
width="14rem"
bodyClassName="gap-3 p-4 text-sm"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt className="text-current/50">owner</dt>
<dd>the survey desk</dd>
<dt className="text-current/50">entries</dt>
<dd className="tabular-nums">{ENTRIES.length}</dd>
<dt className="text-current/50">updated</dt>
<dd>this morning</dd>
</dl>
</Sidebar>
<ShellFoot>
<Status tone="ok">synced</Status>
<span className="tabular-nums">{ENTRIES.length} entries</span>
</ShellFoot>
<ShellLayer open={viewing} onOpenChange={setViewing} label="entry 3">
<div className="mx-auto max-w-prose p-8 text-sm leading-6">
{ENTRIES[2]?.body}
</div>
</ShellLayer>
</Shell>
)
}
// #endregion
export default function ShellDemo() {
return (
<div className="space-y-8">
<Sample
name="plain"
glance="large"
title="an app's frame"
description="⌘B folds the projects (kept across reloads), ⌥⌘B the details; the toggles never move. open on entry 3 lays a viewer between the bar and the foot, and the bar shows its own controls."
with="data frame"
>
<Frame
remember={PROJECTS_OPEN}
className="rounded-xl ring-1 ring-foreground/10"
/>
</Sample>
<Sample name="glass" title="in glass" with="data frame">
<MaterialProvider material="glass">
<Frame className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center" />
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useEscape } from "@/registry/base-nova/ui/key-router"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
glassFront,
glassVariants,
schemeOf,
} from "@/registry/base-nova/ui/material-glass"
import {
NO_SCROLLBAR,
ScrollThumbs,
ScrollTop,
} from "@/registry/base-nova/ui/scroll-thumb"
import {
SidebarPlaced,
type SidebarState,
SidebarToggle,
} from "@/registry/base-nova/ui/sidebar"
// THE FRAME OF AN APP PAGE, and nothing about what goes inside it: one bar across the
// top, a sidebar on either hand (the `sidebar` item, placed here), the main content
// between them, a slim foot, and a layer over the middle that keeps the bar (a viewer).
//
// const nav = useSidebar({ side: "left", label: "projects", remember: NAV })
// const info = useSidebar({ side: "right", label: "details" })
// <Shell>
// <ShellBar left={nav} right={info} start={title} center={views} end={search} />
// <Sidebar sidebar={nav}>…</Sidebar>
// <ShellMain>…</ShellMain>
// <Sidebar sidebar={info}>…</Sidebar>
// <ShellFoot>synced</ShellFoot>
// <ShellLayer open={open} onOpenChange={setOpen} label="viewer">…</ShellLayer>
// </Shell>
//
// The parts place themselves (flex `order`; a sidebar by its side), so their order in
// the JSX is free. The WINDOW scrolls the main content, under the bar and the foot,
// which stick to the view's top and bottom; each sidebar sticks between them and
// scrolls itself. Given a `height`, the shell is a box that scrolls itself instead (a
// page within a page, an example), the same in every other respect.
//
// The bar's ends belong to the sidebars: `left`'s toggle is its first slot and
// `right`'s its last, drawn whether the sidebar is open or closed, so a toggle never
// moves and nothing between them moves either. A sidebar opening changes only the
// main content's width.
//
// The bar's and the foot's heights are measured and told as `--shell-bar` and
// `--shell-foot` (px): on the shell, and, when the window scrolls, on the document's
// root as well, which also takes them as `scroll-padding-top` and `-bottom`, so a
// focused element, an anchor and `ScrollTop` (`toTop`) all stop clear of them.
// `useShell()` gives the same numbers and whether a layer is open (a bar showing other
// controls over a viewer).
//
// Plain: the page's ground and its hairlines. Glass: the bar and the foot are the
// material's front surfaces, blurring what passes under them; the layer a glass body.
export interface ShellState {
/** A layer is open: the bar may show its controls instead. */
layer: boolean
/** The bar's and the foot's measured heights, px (0 before the first reading, and
* for a foot the shell lacks). */
bar: number
foot: number
/** The scroller of a shell given a `height`; null when the window scrolls. */
scroller: React.RefObject<HTMLDivElement | null> | null
}
interface ShellContextValue extends ShellState {
material: Material
setBar: (node: HTMLElement | null) => void
setFoot: (node: HTMLElement | null) => void
addLayer: () => () => void
}
const ShellContext = React.createContext<ShellContextValue | null>(null)
function useShellContext(part: string): ShellContextValue {
const ctx = React.useContext(ShellContext)
if (!ctx) throw new Error(`${part}: place it inside a <Shell>`)
return ctx
}
/** The shell this is inside: whether a layer is open, its bar's and foot's heights. */
export function useShell(): ShellState {
return useShellContext("useShell")
}
/** Shells whose window scrolls: one page, one frame. Two would write the document's
* root over each other. */
let pageShells = 0
/** A sidebar in the shell: sticky between the bar and the foot, as tall as the view
* between them, on its side of the main content. */
const SIDEBAR =
"sticky top-(--shell-bar) z-10 h-(--shell-middle) self-start data-[side=left]:order-1 data-[side=right]:order-3"
export type ShellProps = Omit<React.ComponentProps<"div">, "style"> & {
/** A box of this height, a CSS length, that scrolls itself; unset, the window
* scrolls (the page is the shell). */
height?: string
/** A way back to the top (scroll-thumb's `ScrollTop`) once the main content is
* more than a view down, above the foot. */
toTop?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
style?: React.CSSProperties
}
export function Shell({
height,
toTop = false,
material,
scheme,
className,
style,
children,
ref,
...props
}: ShellProps) {
const m = useMaterial(material)
const glass = m === "glass"
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
const scroller = React.useRef<HTMLDivElement | null>(null)
const boxed = height !== undefined
const [barEl, setBar] = React.useState<HTMLElement | null>(null)
const [footEl, setFoot] = React.useState<HTMLElement | null>(null)
const [heights, setHeights] = React.useState<{
bar: number
foot: number
} | null>(null)
React.useLayoutEffect(() => {
const read = () => {
const bar = barEl?.getBoundingClientRect().height ?? 0
const foot = footEl?.getBoundingClientRect().height ?? 0
setHeights((was) =>
was?.bar === bar && was.foot === foot ? was : { bar, foot },
)
}
read()
const sizes = new ResizeObserver(read)
if (barEl) sizes.observe(barEl)
if (footEl) sizes.observe(footEl)
return () => sizes.disconnect()
}, [barEl, footEl])
// The window's shell tells the document's root: what lies outside the shell reads
// the heights there, and the page's own scroll keeps clear of the bar and the foot.
React.useEffect(() => {
if (boxed) return
pageShells++
if (pageShells > 1)
throw new Error(
"Shell: two shells scroll the window at once; a page has one frame (give a shell inside a page a `height`)",
)
// The window is the shell's long scroller: its own bar hidden (it ran the
// window's whole height beside the bar and dented the bar's end with its gutter),
// the shell's thumbs drawn between the bar and the foot instead.
const hide = NO_SCROLLBAR.split(" ")
document.documentElement.classList.add(...hide)
return () => {
pageShells--
document.documentElement.classList.remove(...hide)
}
}, [boxed])
React.useLayoutEffect(() => {
if (boxed || !heights) return
const doc = document.documentElement.style
doc.setProperty("--shell-bar", `${heights.bar}px`)
doc.setProperty("--shell-foot", `${heights.foot}px`)
doc.scrollPaddingTop = `${heights.bar}px`
doc.scrollPaddingBottom = `${heights.foot}px`
return () => {
doc.removeProperty("--shell-bar")
doc.removeProperty("--shell-foot")
doc.removeProperty("scroll-padding-top")
doc.removeProperty("scroll-padding-bottom")
}
}, [boxed, heights])
const [layers, setLayers] = React.useState(0)
const addLayer = React.useCallback(() => {
setLayers((n) => n + 1)
return () => setLayers((n) => n - 1)
}, [])
const layer = layers > 0
// Before the first reading nothing is told: a sidebar's height and the main
// content's floor stay unset (their own heights) rather than wrong.
const view = height ?? "100dvh"
const vars: Record<string, string> = heights
? {
"--shell-bar": `${heights.bar}px`,
"--shell-foot": `${heights.foot}px`,
"--shell-middle": `calc(${view} - ${heights.bar + heights.foot}px)`,
}
: {}
const frameVars = { "--shell-height": height, ...vars } as React.CSSProperties
const value: ShellContextValue = {
layer,
bar: heights?.bar ?? 0,
foot: heights?.foot ?? 0,
scroller: boxed ? scroller : null,
material: m,
setBar,
setFoot,
addLayer,
}
const place = React.useMemo(
() => ({ className: SIDEBAR, inert: layer }),
[layer],
)
const flow = (
<MaterialProvider scheme={s}>
<ShellContext.Provider value={value}>
<SidebarPlaced place={place}>{children}</SidebarPlaced>
</ShellContext.Provider>
</MaterialProvider>
)
const own = {
...props,
"data-material": m,
}
if (!boxed)
return (
<div
ref={here}
data-slot="shell"
{...own}
className={cn(
"isolate flex min-h-dvh flex-wrap content-start text-foreground",
glass && "[--glass-body:1]",
schemeOf(s),
className,
)}
style={{ ...vars, ...style } as React.CSSProperties}
>
{/* Glass: the window's frame is one glass body under everything, held to the
view (the page scrolls over it); the bar, the foot and the sidebars are
its fronts. */}
{glass && <GlassBack className="fixed" />}
{flow}
<ScrollThumbs target="window" />
{toTop && <ScrollTop target="window" />}
</div>
)
return (
// A box: what is fixed inside it (the layer) is placed by it (`contain:
// layout`), never by the window, and it scrolls on its own scroller.
<div
ref={here}
data-slot="shell-frame"
{...own}
className={cn(
"relative isolate h-(--shell-height) overflow-clip text-foreground [contain:layout]",
glass && "[--glass-body:1]",
schemeOf(s),
className,
)}
style={{ ...frameVars, ...style }}
>
{/* Glass: the frame is one glass body behind all it holds, as a Panel's; the
bar, the foot and the sidebars are its fronts, and the content stands on
it, never on the picture behind. */}
{glass && <GlassBack className="absolute" />}
<div
ref={scroller}
data-slot="shell-scroller"
// edge: the page's own scroll, boxed; the bar and the foot end it.
className={cn("h-full overflow-y-auto", NO_SCROLLBAR)}
style={
heights
? {
scrollPaddingTop: `${heights.bar}px`,
scrollPaddingBottom: `${heights.foot}px`,
}
: undefined
}
>
<div
data-slot="shell"
className="flex min-h-full flex-wrap content-start"
>
{flow}
</div>
</div>
<ScrollThumbs scroller={scroller} />
{toTop && <ScrollTop scroller={scroller} />}
</div>
)
}
/** Plain, a strip of the page's ground; glass, the material's front surface. */
const PLAIN_STRIP = "border-border bg-background"
/** A glass shell's body: one surface of the material behind everything the frame
* holds, Panel's back layer (its corners the frame's), so what stands on it (the
* content) is read on glass, and the bar, the foot and the sidebars are its fronts. */
function GlassBack({ className }: { className: string }) {
return (
<div
aria-hidden
data-slot="shell-back"
className={cn(
glassVariants({ shape: "surface", edge: false }),
"pointer-events-none inset-0 -z-10 [--glass-radius:inherit]",
className,
)}
/>
)
}
export interface ShellBarProps
extends Omit<React.ComponentProps<"header">, "children"> {
/** The left sidebar, whose toggle is the bar's first slot. */
left?: SidebarState
/** The right sidebar, whose toggle is the bar's last slot. */
right?: SidebarState
/** At its start, after the left toggle: a title, a breadcrumb. */
start?: React.ReactNode
/** In its middle, centred on the bar: a segmented choice, a title. */
center?: React.ReactNode
/** At its end, before the right toggle: a search, an account. */
end?: React.ReactNode
}
/** THE BAR, sticky at the top. Its first and last slots are the sidebars' toggles,
* each in its own column, so neither moves and nothing between them moves when a
* sidebar opens; `start`, `center` and `end` lie between them, the centre on the
* bar's own middle. */
export function ShellBar({
left,
right,
start,
center,
end,
className,
ref,
...props
}: ShellBarProps) {
const ctx = useShellContext("ShellBar")
if (left && left.side !== "left")
throw new Error("ShellBar: `left` is a sidebar on the right")
if (right && right.side !== "right")
throw new Error("ShellBar: `right` is a sidebar on the left")
const glass = ctx.material === "glass"
const setBar = ctx.setBar
const both = React.useCallback(
(node: HTMLElement | null) => {
setBar(node)
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[setBar, ref],
)
return (
<header
ref={both}
data-slot="shell-bar"
{...props}
className={cn(
// `self-start`: its height is what it holds, never its line's, so the
// height measured never depends on a length the measure sets.
"sticky top-0 z-30 order-first grid min-h-12 basis-full self-start grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2",
glass ? glassFront.top : [PLAIN_STRIP, "border-b"],
className,
)}
>
{left && <SidebarToggle sidebar={left} className="col-start-1" />}
{/* With a centre, the two sides share the room equally so the centre is the
bar's true middle; without one, the start takes all the room the end does
not need (an equal share squeezed a full start into a third of the bar and
drew it over the end). A side that still does not fit clips at its own edge,
never over its neighbour. */}
<div
data-slot="shell-bar-slots"
className={cn(
"col-start-2 grid items-center gap-3",
center === undefined
? "grid-cols-[minmax(0,1fr)_auto]"
: "grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]",
)}
>
<div
data-slot="shell-bar-start"
className="flex min-w-0 items-center gap-2 overflow-x-clip"
>
{start}
</div>
{center !== undefined && (
<div
data-slot="shell-bar-center"
className="flex items-center justify-center gap-2"
>
{center}
</div>
)}
<div
data-slot="shell-bar-end"
className="flex min-w-0 items-center justify-end gap-2 overflow-x-clip"
>
{end}
</div>
</div>
{right && <SidebarToggle sidebar={right} className="col-start-3" />}
</header>
)
}
/** THE MAIN CONTENT, between the sidebars, scrolled by the window (or the shell's
* box). Out of reach while a layer lies over it. */
export function ShellMain({
className,
children,
...props
}: React.ComponentProps<"main">) {
const ctx = useShellContext("ShellMain")
return (
<main
data-slot="shell-main"
inert={ctx.layer}
{...props}
className={cn(
"relative isolate order-2 min-h-(--shell-middle) min-w-0 flex-1",
className,
)}
>
{/* A sidebar in the content (beside a panel's list) is the content's own. */}
<SidebarPlaced place={UNPLACED}>{children}</SidebarPlaced>
</main>
)
}
const UNPLACED = {}
/** THE FOOT, slim, sticky at the bottom: status lines. */
export function ShellFoot({
className,
ref,
...props
}: React.ComponentProps<"footer">) {
const ctx = useShellContext("ShellFoot")
const glass = ctx.material === "glass"
const setFoot = ctx.setFoot
const both = React.useCallback(
(node: HTMLElement | null) => {
setFoot(node)
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[setFoot, ref],
)
return (
<footer
ref={both}
data-slot="shell-foot"
{...props}
className={cn(
"sticky bottom-0 z-30 order-last flex min-h-8 basis-full self-start items-center gap-4 px-3 text-current/60 text-xs",
glass ? glassFront.bottom : [PLAIN_STRIP, "border-t"],
className,
)}
/>
)
}
export interface ShellLayerProps
extends Omit<React.ComponentProps<"div">, "children"> {
open: boolean
onOpenChange: (open: boolean) => void
/** What it shows, for a screen reader ("viewer"). */
label: string
children: React.ReactNode
}
/** A LAYER over the main content and the sidebars, between the bar and the foot (a
* viewer that keeps the bar). Open, it takes the focus and what lies under it is out
* of reach; Esc closes it (key-router's ladder), and the focus goes back where it
* was. Its scroll is its own: the page under it never moves. */
export function ShellLayer({
open,
onOpenChange,
label,
className,
children,
...props
}: ShellLayerProps) {
const ctx = useShellContext("ShellLayer")
const glass = ctx.material === "glass"
const layer = React.useRef<HTMLDivElement>(null)
const addLayer = ctx.addLayer
React.useEffect(() => (open ? addLayer() : undefined), [open, addLayer])
useEscape(() => onOpenChange(false), { active: open, root: layer })
React.useEffect(() => {
if (!open) return
const before = document.activeElement
layer.current?.focus({ preventScroll: true })
return () => {
const now = document.activeElement
if (
before instanceof HTMLElement &&
before.isConnected &&
(now === null || now === document.body)
)
before.focus({ preventScroll: true })
}
}, [open])
if (!open) return null
return (
<div
ref={layer}
role="dialog"
aria-label={label}
tabIndex={-1}
data-slot="shell-layer"
{...props}
className={cn(
glass
? [
glassVariants({ shape: "surface", edge: false }),
"[--glass-radius:0px]",
]
: "bg-background",
"fixed inset-x-0 top-(--shell-bar) bottom-(--shell-foot) z-20 overflow-y-auto overscroll-contain outline-none",
NO_SCROLLBAR,
className,
)}
>
<SidebarPlaced place={UNPLACED}>{children}</SidebarPlaced>
</div>
)
}