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,
} 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>
  <ToggleGroup aria-label="Panels" multiple onValueChange={onToggle} value={active}>
    {/* … */}
  </ToggleGroup>
</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.

API Reference

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 is exported, so a wrapper can take the same props without restating them.

On this page