Kanzo UI
Layout

Shell

The window regions — root, header, body, aside, main, footer. Structure only, no aesthetic.

The Amber Hall
The board16 open, 3 overdue
We were paid to be here

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

PropTypeDefault
valueV | null — the open panel, null while collapsed; V is the page's panel id—
onValueChange(value: V | null) => void—
aria-label or aria-labelledbystring — exactly one, required—
size"sm" | "md" | "lg""sm"
spacingnumber2

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

PropTypeDefault
valuestring—
labelstring — the tooltip and the accessible name—
iconComponentType<{ 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

PropTypeDefault
side"start" | "end""start"
overlaybooleanfalse
widthnumber—

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.

On this page