Shell
The window regions — root, header, body, aside, main, footer. Structure only, no aesthetic.
Usage
import {
ShellRoot, ShellHeader, ShellBody, ShellAside, ShellMain, ShellFooter,
ShellDockSwitcher, ShellDockItem,
} from "@kanzo-tech/ui";<ShellRoot>
<ShellHeader>{/* app chrome */}</ShellHeader>
<ShellBody>
<ShellAside aria-label="Navigation" side="start" width={240} />
<ShellMain>{/* the page */}</ShellMain>
<ShellAside aria-label="Inspector" side="end" width={280} />
</ShellBody>
<ShellFooter>{/* status */}</ShellFooter>
</ShellRoot>The regions carry no aesthetic
This is the design decision that matters most here, and it is deliberate.
A region places its children and separates itself from its neighbour. It has no height, no surface, no typography, no font size. Everything you can see inside one is yours.
There is no Toolbar and no StatusBar, and there will not be one. A thin strip with
start/center/end clusters is only a component if it also carries a height, a surface and a font
size — an IDE aesthetic, which is a product decision rather than a system one. A dense strip is
something you put in a region. The workspace showcase shows one;
copy it if you want that look.
This was decided the expensive way. Toolbar, StatusBar and TopBarUtility were three copies of
one strip; merging them into a generic ShellBar kept the fixed height, the card surface, the muted
ink and the eleven-pixel font — the IDE aesthetic of the component the owner had explicitly rejected.
Generalising an implementation while preserving a rejected appearance is not generalising.
What would reverse it: a caller who cannot express the strip they want by putting content in a region. Three attempts produced no such caller; the dense IDE strip now lives in the workspace showcase, where anyone who wants that look copies it.
Held by packages/ui/src/layouts/shell.tsx, the region set;
packages/ui/src/layouts/shell.test.tsx, "declare no role, so the call site owns the landmark" and
"impose no height, surface or typography of their own".
So a status strip is ordinary composition:
<ShellFooter
aria-label="Status"
className="h-[1.625rem] flex-row items-center gap-2 bg-card px-1.5 text-[11px] text-muted-foreground"
role="contentinfo"
>
<span className="min-w-0 flex-1">Ready</span>
<ShellDockSwitcher aria-label="Side panels" onValueChange={setPanel} value={panel}>
{/* … */}
</ShellDockSwitcher>
</ShellFooter>Rules
Exactly one <main> per page. ShellMain renders it. Nested containers use <section> —
two <main> elements are a conformance error and make "skip to main content" ambiguous. The link
that phrase names is SkipNavLink, and ShellMain is what it lands on.
SidebarInset is a neutral offset <div> and carries no landmark. It is a styling wrapper — the
inset margin, radius and shadow — and the ShellMain placed inside it is what holds the region.
shadcn makes SidebarInset the <main> because it has no separate region layer; we do, which is
exactly why our answer differs from the one you will find upstream, and there is nothing that would
reverse it. This is the most-copied false claim in the repository: four documents have asserted
that SidebarInset renders the landmark, and a reader who follows them ships a page with no landmark
and an ambiguous skip link.
Held by packages/ui/src/layouts/shell.test.tsx, "renders exactly one <main>, and it is
ShellMain"; packages/ui/src/composites/sidebar.tsx, the comment above SidebarInset.
A shell has two legal shapes, and they do not mix. Either the header and footer go inside
SidebarInset, right of the rail — the shadcn model, and the canonical one here — or the sidebar is
in-flow (collapsible="none", or a ShellAside) and a spanning header is legitimate. Never both. A
fixed rail (collapsible="icon" or "offcanvas") starts at viewport top and paints over any
header that spans across it, so nothing reverses this either: it is a consequence of the rail's
positioning, not a preference.
Held by packages/ui/src/composites/sidebar.tsx, the rail variants;
packages/ui/src/layouts/shell.tsx, the regions.
Asides are landmarks. ShellAside renders <aside>, so two of them need aria-label to be
told apart. That is also why asides may repeat where <main> may not.
Logical properties, never physical. side="start" | "end" and border-e / border-s, never
left/right — one code path mirrors correctly under RTL.
No region declares a role. A top region is often banner, a bottom one often contentinfo,
a strip is neither, and a shell may have several — so assuming a landmark would mint duplicates.
Pass role and aria-label at the call site.
ShellRoot uses h-dvh, not h-screen. On mobile browsers 100vh includes the retracting
URL bar, so a shell sized with it is taller than the visible viewport and its bottom region sits
off screen.
Asides
side decides which edge carries the divider. width is the docked width in px — a computed
value the caller owns, which is why it is inline style rather than a class.
overlay turns the aside into a full-container drawer that floats over the content, for narrow
viewports. It has no divider, and width is ignored: it fills its container via inset-0.
Resizing is composed, not a prop. Wrap Ark's Splitter around the aside and the drag,
keyboard resizing and ARIA come from the machine. A resizable prop would mean re-implementing
behaviour Ark already ships.
The dock switcher
A page whose side panel holds one of several tools switches them with ShellDockSwitcher, VS Code's
activity bar: an icon per panel, named by its tooltip and its accessible name, checked while its
panel is open, and pressed again to collapse it — value becomes null. The panel's own title says
what it is in words. Put it in whichever region keeps it: a ShellFooter, as above, or a vertical
strip beside the dock with orientation="vertical".
type Panel = "info" | "ask";
const [panel, setPanel] = useState<Panel | null>("info");
<ShellDockSwitcher aria-label="Side panels" value={panel} onValueChange={setPanel}>
<ShellDockItem value="info" label="Info" icon={InfoIcon} />
<ShellDockItem value="ask" label="Ask" icon={MessageCircleIcon} />
</ShellDockSwitcher>
{panel ? <ShellAside aria-label={labelOf(panel)} side="end">{/* … */}</ShellAside> : null}It is a single-select ToggleGroup, so its roles, roving focus and checked
state are Ark's, and each item is a ToggleGroupItem whose child is the Tooltip trigger — the
nesting that keeps the group's items collectable; the other one leaves the arrow keys dead with no
error. Why one part and not a pattern per page is on the shell's dock.
It is generic over the page's panel id, inferred from value: onValueChange hands back
Panel | null, so setPanel takes it as it is, with nothing to narrow.
It has no name of its own. A single-select group is a radiogroup, and WAI-ARIA requires one to
be named; no default fits every dock, so the type asks for exactly one of aria-label or
aria-labelledby, as ButtonGroup does. Name what the panels are — Side panels, Inspectors,
or the one panel's own name when there is only one.
Testing
DockHarness from @kanzo-tech/testing drives a ShellDockSwitcher, a
radiogroup named by the page; DockHarness.with({ name }) picks one by that name, and the bare
harness takes the page's only one. open(panel) presses an item by its label and waits for it to be
checked, and close() presses the open one again and waits for none to be.
import { DockHarness } from "@kanzo-tech/testing";
const dock = await env.getHarness(DockHarness.with({ name: "Side panels" }));
await dock.open("Ask");
await dock.close();API Reference
ShellDockSwitcher
| Prop | Type | Default |
|---|---|---|
value | V | null — the open panel, null while collapsed; V is the page's panel id | — |
onValueChange | (value: V | null) => void | — |
aria-label or aria-labelledby | string — exactly one, required | — |
size | "sm" | "md" | "lg" | "sm" |
spacing | number | 2 |
Takes the rest of ToggleGroup's props — orientation, variant, loopFocus — except multiple,
which it fixes to false, and defaultValue: the page owns which panel is open, because it draws
the panel.
ShellDockItem
| Prop | Type | Default |
|---|---|---|
value | string | — |
label | string — the tooltip and the accessible name | — |
icon | ComponentType<{ className?: string }> | — |
Takes the rest of ToggleGroupItem's props, such as disabled. Its data-slot is
shell-dock-item, and the switcher's is shell-dock-switcher.
ShellAside
| Prop | Type | Default |
|---|---|---|
side | "start" | "end" | "start" |
overlay | boolean | false |
width | number | — |
Extends ComponentProps<typeof ark.aside>. side is mirrored to data-side and decides which
edge carries the divider; width is applied as an inline style — the sanctioned exception,
because it is a computed value the caller owns — and is ignored when overlay, where the aside
fills its container.
The other regions
ShellRoot, ShellHeader, ShellBody and ShellFooter have no props of their own and extend
ComponentProps<typeof ark.div>; ShellMain extends ComponentProps<typeof ark.main>.
asChild, ref, role and aria-label pass through — no region declares a landmark role
itself, so the call site names them.
ShellAsideProps, ShellDockSwitcherProps and ShellDockItemProps are exported, so a wrapper can
take the same props without restating them.