Skip to main content
WireKit
Copy for LLM

App Shell

A full-page layout wrapper that orchestrates header, sidebar, and main content areas. Build dashboard layouts with minimal markup.

When to use this vs. a standalone sidebar. App-shell is the canonical full-page shape — sticky header, push-not-overlay sidebar, mobile-collapse handled automatically. Use it for dashboards, admin pages, and any layout where the navigation is part of the page chrome. For a standalone sidebar (a marketing-page resource navigator, a settings drawer inside a card), see Sidebar instead.

navbar is an alternative shell, not a building block for a shell that has its own navigation column. <x-wirekit::navbar> is a self-contained top-nav layout carrying its OWN mobile hamburger, and this shell turns a sidebar or rail slot into an off-canvas drawer below lg — so nesting the two gives a phone two hamburgers controlling two different menus. A shell with NEITHER slot opens no drawer and renders no hamburger of its own, so there is only one menu and the pairing is fine. For the top bar of a shell that DOES carry a navigation column, compose <x-wirekit::header> with a <x-wirekit::sidebar.toggle class="lg:hidden" /> (see With Sidebar).

Basic Usage

App shell with header and main
Dashboard
John

Welcome

Your dashboard content goes here.

With Sidebar

App shell with sidebar

Flush Column Layout

The default puts the sidebar in a padded column as a card. The other common admin shape is the flush column: the sidebar meets the shell's edges with nothing between, and the topbar begins beside it rather than above it, so the navigation runs the full height.

Two props, and they are meant to be used together — one removes the gap around the column, the other removes the card inside it:

Flush sidebar column with the topbar beside it

Dashboard

Only the content region scrolls — the navigation column and the topbar stay put.

header-placement="content" moves the header slot inside the content column. The sidebar column then owns the full height of the shell, which is what lets the navigation and the topbar meet at a single vertical edge.

The sidebar's own variant="flush" and its header / footer slots do the rest — the fixed brand row, the scrolling navigation, and the pinned account row. See Sidebar for those.

Collapsible Sidebar

The sidebar's collapsible icon rail works inside the app shell. When the rail collapses, the shell's sidebar column tracks the narrower width so the main content reclaims the freed space — click the chevron toggle at the bottom of the sidebar.

App shell with a collapsible sidebar — the main content reflows when the rail collapses
App
User

Dashboard

Collapse the rail — this content widens to fill the reclaimed space.

Sticky header stays fixed on scroll
Sticky App

Content scrolls under the sticky header.

Container Layout

The :container="true" prop on <x-wirekit::header> and <x-wirekit::main> constrains their inner content to the --size-wk-container-2xl token (96rem = 1536px) and centers it horizontally. At preview-iframe widths the 96rem ceiling never engages, so the demo below uses an explicit narrower style="max-width: 36rem" override on the centering wrappers to make the centering effect visible at typical preview sizes.

Contained header and main — inner content centered inside the dashed outer box

Centered Content

Content is width-constrained and centered. The dashed outline marks the full-width header and main wrappers; the centered block above is the inner container.

The console layout — a module rail beside the navigation

Pass a rail slot and the shell grows a third column: a full-height strip of application areas on the outside, the selected area's own navigation beside it, the content to the right. The second column is optional — leave the sidebar slot out and the content begins directly after the rail, which is what an application with one navigation level wants.

Put a <x-wirekit::shell-bar> at the head of each column and the shell's horizontal rule runs across all of them at one height. That is not a coincidence to maintain: the bars are the same component reading the same token, so the segments cannot drift apart.

A shell has no outer edge of its own. It fills the browser window, so in a real application its outer edges ARE the window's edges — square, and as wide as the screen. Every radius in this family faces INWARD, toward the inset content panel; nothing faces out. The previews below carry no frame for the same reason: the box they sit in is standing in for the window, and a shell drawn inside a second border is a window inside a window.

Rail, navigation, content — one rule across all three

Terminal A920 Pro 1

Content fills the available width. Pass a `max` tier to box it instead.

header-placement="content" is what puts the top bar inside the content column, so the two navigation columns run the full height beside it. Note also max="none" on the content: main boxes itself to a container width by default, which is right for a document and wrong for a console. Pass a max tier when you want it boxed.

panel and the shared rule are different shapes

This example is flush on purpose. panel lifts the content column off the chrome with an even inset, so its head sits lower than the heads of the columns beside it — and then there is no single rule across all three, because the content is no longer in the same plane as the chrome.

Both shapes are correct; they answer different questions. Flush columns give you one continuous rule separating the workspace mark, the module name and the page's navigation — the console layout. An inset panel gives you chrome with a sheet lying on it, where the sheet carries its own head. Reach for the first when the three columns are one surface, and the second when the content is a document on top of one.

Below the breakpoint

The rail and the second column travel as one off-canvas panel rather than sliding independently — two panels moving at different offsets land as a seam, and one object with a width that is simply the sum of its columns does not. Above the breakpoint the wrapper is display: contents, so the desktop layout does not route through it at all.

Drive the drawer the same way as a shell without a rail: the shell owns a sidebarOpen flag, so a trigger inside it can flip it.

{{-- 1. Any control inside the shell can open the drawer — the flag lives on the shell root. --}}
<x-wirekit::button x-on:click="sidebarOpen = true" class="lg:hidden">
    Menu
</x-wirekit::button>

Both columns are layout, not landmarks

The two <aside> elements this shell wraps the rail and sidebar slots in carry role="presentation". If you audit landmarks, that is a difference you will see after upgrading — so here is what it is there instead of.

Both columns are scaffolding. The landmark is the <nav> inside each — <x-wirekit::app-rail> and <x-wirekit::sidebar> each render one, and each already carries its own accessible name. The asides around them add a second region apiece, and a bare <aside> here is an implicit complementary landmark, so the console layout emitted two nameless complementary regions. Not as the result of misusing it: the composition documented above produced them, and axe reports landmark-unique on exactly that.

Naming the two asides was the other way out, and it is the worse one — it satisfies the rule by turning one redundant landmark into two named redundant landmarks, and a screen-reader user is then handed two more regions to skip past that describe nothing but the layout.

Where a real landmark is genuinely wanted, the slot says so. Each slot's own attributes reach its column, and they are merged as defaults, so a role you pass replaces the presentation one rather than sitting beside it:

{{-- 1. The rail column becomes a named complementary landmark; the default steps aside. --}}
<x-slot:rail role="complementary" aria-label="Areas">
    <x-wirekit::app-rail indicator="edge">
        <x-wirekit::app-rail.item href="#" icon="home" label="Overview" />
    </x-wirekit::app-rail>
</x-slot:rail>

The sidebar slot of a console shell takes the same treatment.

The sidebar-only shell is deliberately different

A shell with a sidebar slot and no rail gets no presentation default. Its single column stays a plain <aside> and keeps the implicit complementary landmark it has always had.

That asymmetry is a decision rather than an oversight. There is only one column there, so nothing collides with anything, and a lone complementary region beside the content is a reasonable thing for an application to have — the duplicate-landmark problem above needs two columns to exist at all. Defaulting it to presentation for symmetry's sake would take a landmark away from every sidebar-only shell shipping today, silently, in a release nobody would connect to it.

Props

App Shell

Prop Type Default Description
sidebarInset bool true Whether the in-flow sidebar column keeps its top and inline-start inset. false is the full-bleed navigation column — the sidebar meets the shell's top and inline-start edge with nothing between. Pair it with <x-wirekit::sidebar variant="flush">: this removes the gap around the column, that removes the card inside it.
headerPlacement 'shell' | 'content' 'shell' Where the header slot renders. shell spans the full width above the sidebar row. content puts it inside the content column, so the sidebar runs the full height and the topbar begins beside it.
panel bool false Renders the content column as an inset rounded surface floating on the shell's own background, instead of meeting it edge to edge. lg and up only — below the breakpoint the navigation is a drawer and the content is the whole viewport, so an inset there spends room a phone does not have on a decoration nothing is beside
tone 'default' | 'muted' | 'inverse' 'default' The shell's own chrome surface, via the same --color-wk-rail-* roles a rail and a toned sidebar read. default is unchanged from every existing shell. A rail inside a toned shell inherits the tone unless it sets its own, and a variant="flush" sidebar follows too — it paints nothing, so the chrome is its surface. There is no accent here: that tone inverts on hover, which a generic component inside the column cannot follow. Tone the rail instead, which is how the reference consoles are built
viewport bool false Pins the shell to the viewport height (100dvh) so the sidebar and main region scroll internally — the brand stays at the top, the account menu at the bottom. The default (false) keeps the shell at min-h-screen, growing with its content so the page scrolls as a document.
scope string|null null Scoped personalization name

headerPlacement="content" turns off a sticky header, and nothing reports it. position: sticky sticks to the nearest scrolling ancestor. In content mode that ancestor is the content column, which is overflow-hidden and therefore never scrolls — so the header simply does not stick, and no error appears anywhere.

You cannot repair it with CSS from the outside: removing the column's overflow-hidden makes a wide table push the column past the shell and squeeze the sidebar instead of scrolling inside itself.

Use viewport instead, where the question dissolves rather than being answered: <x-wirekit::main> becomes the scroller and the header sits above it without moving at all, so it never needed sticky.

Prop Type Default Description
sticky bool false Sticky header with z-index
container bool false Constrain content to max-width
scope string|null null Scoped personalization name

Main

Prop Type Default Description
container bool false Constrain content to max-width
padding string 'lg' Content padding: none, sm, md, lg, xl
scope string|null null Scoped personalization name

Brand

Prop Type Default Description
logo string|null null Logo image URL
name string|null null Brand name text
href string '/' Link destination
scope string|null null Scoped personalization name

Profile

Prop Type Default Description
avatar string|null null Avatar image URL
name string|null null User display name
scope string|null null Scoped personalization name
Prop Type Default Description
scope string|null null Scoped personalization name

Keyboard Interaction

Above the breakpoint this component is a layout wrapper, and keyboard interaction is delegated to its children.

Below it, the navigation is a modal drawer, and the shell owns its keyboard model. Those are not two presentations of one thing — the same element is layout on a wide screen and a dialog on a narrow one, so the behavior below applies only while the drawer is open at that width.

Key What happens
Escape Closes the drawer and returns focus to the control that opened it
Tab / Shift+Tab Cycles within the drawer. Focus cannot leave it while it is open

Opening moves focus into the drawer; closing returns it to the toggle. While it is open the panel carries role="dialog" and aria-modal="true", and the toggle's aria-controls names it.

Why the focus trap is not cosmetic The backdrop blocks pointers from the page behind it. It does not block the keyboard. Without the trap, focus walked on through controls the backdrop was covering — visible to nobody, operable only by the people who cannot see where focus went. That asymmetry is why the defect survived every check made with a mouse.

Widening the window past the breakpoint releases the trap and closes the drawer, because the element becomes ordinary layout there and a trap would hold focus inside columns that cover nothing.

Pitfalls

  • Don't nest two app-shells. The component wires position: fixed headers and sidebars; nesting causes overlap and breaks the responsive collapse. One per page.

Design Tokens

Token Used for
--color-wk-bg Shell background
--color-wk-bg-elevated Header background
--color-wk-border Header border
--z-wk-sticky Sticky header z-index
--transition-wk-duration Sidebar animation
--space-wk-* Main content padding
--size-wk-container-2xl Container max-width

Was this page helpful?

Voting requires cookies or local storage. What we store