AppShell
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/app-shell.jsonThe frame a product draws once and then never thinks about. It is three boxes and a grid, which is exactly why every product wrote its own — and why every one of them got the same two things wrong before getting them right.
<AppShell top={<Titlebar />} side={<NavRail label={t('nav.screens')} items={screens} />}> <Screen> <PageHeader title={t('catalogue.title')} actions={<Button>{t('new')}</Button>} /> <Routes>{/* … */}</Routes> </Screen></AppShell>The two failures it exists to prevent. A grid item’s min-width and
min-height default to auto, not zero, so a track measures its content
instead of its share of the window. Without min-h-0 on the rail, its border
and footer stopped halfway down a tall window; without min-w-0 on the
content column, one wide table pushed the whole screen out from under the
sidebar and there was nothing left to scroll. Both were found in a shipped
build, by eye, months apart. Neither is something you should have to remember.
The window never scrolls, on either axis. Screen is handed the height
the window has left and scrolls inside itself, so the bottom edge of the
content is the bottom edge of the window and a long page announces itself with
a bar rather than hiding its end. A desktop window has a bottom edge; content
that runs past it the way a web page does pretends it does not.
flow or held. flow is the common case: the screen is as long as its
content and scrolls within the height it was given. held is for a screen
that lays its own boxes out against that height — a table with a sticky
header, two columns that each scroll — and so must not grow; it scrolls
nowhere itself, something inside it does. Chosen by mistake, held clips and
flow never does, which is why flow is the default.
The bar runs across the rail. It spans every column, as a system title bar would: the mark sits where the system prints the name, and the window’s own buttons sit at the far end of the same strip. A bar that started after the rail left a notch in the top-left corner that nothing could fill.
The sizes come from the theme. --spacing-titlebar and --spacing-rail
are the line’s, so four products that each draw their own chrome stop each
picking their own number — which is how one ended up at 40px and another at
2.4rem. Override topHeight and sideWidth where a product genuinely
differs; a number is pixels and a string is any CSS track size, for a rail
that is dragged. A rail that collapses to its icons is
sideWidth="var(--spacing-rail-compact)" — 56px, the width
NavRail’s collapsed column is drawn for.
Leave out what you do not have. Without side the shell is a one-column
grid rather than a two-column grid with an empty column still eating its
track, and the same for top.
What it does not decide: what is in the bar, what is in the rail, or how you navigate. Those differ more than they look — one product of the line draws its own title bar with the window buttons in it, another a web header with a phone’s bottom bar under it. This is the geometry they share. See NavRail for the destinations and WindowFrame for a window with no system frame.
Props
Read from the types in ui/app-shell.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
AppShell
The frame.
| Prop | Type | Default | |
|---|---|---|---|
top | ReactNode | The strip across the top, full width - a title bar, a header, a banner row. Leave it out and the rail runs to the top of the window. | |
side | ReactNode | The rail down the left. Leave it out for a product that navigates from the top bar alone, which is the usual shape on the web. | |
sideWidth | string | number | 'var(--spacing-rail)' | How wide the rail is. A number is pixels; a string is any CSS track size, for a rail that collapses or is dragged. Defaults to the theme's --spacing-rail, so four products stop each choosing their own. |
topHeight | string | number | 'var(--spacing-titlebar)' | How tall the top bar is, same rules, defaulting to --spacing-titlebar. It is a fixed track rather than auto on purpose: a bar that grows by a pixel when its content changes moves every screen under it. |
childrenrequired | ReactNode |
And every prop of React's HTMLAttributes<HTMLDivElement>, passed on.
Screen
One screen inside the frame, handed the height the window has left.
| Prop | Type | Default | |
|---|---|---|---|
scroll | flow | held | 'flow' | |
pad | default | tight | none | 'default' |
And every prop of React's HTMLAttributes<HTMLElement>, passed on.
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 17 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/app-shell.tsx - the file a product copies - newest first.