Skip to content

AppShell

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/app-shell.json
See it liveThe frame with a bar, a rail and a screen in it - and the same frame without a rail, which is the usual shape on the web.→

The 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.

PropTypeDefault
topReactNodeThe 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.
sideReactNodeThe 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.
sideWidthstring | 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.
topHeightstring | 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.
childrenrequiredReactNode

And every prop of React's HTMLAttributes<HTMLDivElement>, passed on.

Screen

One screen inside the frame, handed the height the window has left.

PropTypeDefault
scrollflow | held'flow'
paddefault | 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.

  1. v0.39.0
    • feat let the frame go on paper, and keep a panel or a chart on one sheet 354d583
  2. v0.29.0
    • feat the frame, with the two mistakes already made 72958c9