Skip to main content
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.

What you write in your own application — viewport pins the shell to the window, so it needs no height of its own:

<x-wirekit::app-shell viewport>
    <x-slot:header>…</x-slot:header>
    …
</x-wirekit::app-shell>

Printed, a viewport shell grows to its content, so the main column prints in full, over as many sheets as it needs, rather than the one screen it shows. On a sheet narrower than lg, where the navigation is a drawer, the navigation stays off paper; on a wider one it prints as the column it is on screen.

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.

A drawer that holds the rail alone shows the rail's module names, because on a touch screen a tooltip never gets the chance to appear. Beside a second column the rail keeps its narrow form, which is what fits a phone there.

The open drawer carries its own close button at its top, on the spot a header's toggle occupies when the header sits in the content column: that toggle ends up underneath the drawer. Escape and a tap on the backdrop close it as well.

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 an <aside> carries.

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 least as tall as the screen, growing with its content so the page scrolls as a document. Both give back what a strip above the page takes, so the account menu stays on screen under an environment line, and both read --wk-shell-height first. On a page whose viewport meta tag sets viewport-fit to cover, the pinned shell also keeps its header, its sides and the navigation drawer below the breakpoint clear of a notch, a camera island and a home indicator.
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.

Documented on its own page: Header — props, slots and pitfalls. The table that stood here listed a subset and drifted; a component with a page of its own is cross-linked rather than re-pasted, because the copy is always the one that rots.

Main

Documented on its own page: Main — props, slots and pitfalls. The table that stood here listed a subset and drifted; a component with a page of its own is cross-linked rather than re-pasted, because the copy is always the one that rots.

Brand

Documented on its own page: Brand — props, slots and pitfalls. The table that stood here listed a subset and drifted; a component with a page of its own is cross-linked rather than re-pasted, because the copy is always the one that rots.

Profile

Documented on its own page: Profile — props, slots and pitfalls. The table that stood here listed a subset and drifted; a component with a page of its own is cross-linked rather than re-pasted, because the copy is always the one that rots.

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
Enter / Space on the close button Closes the drawer and returns focus to the control that opened it

Opening moves focus into the drawer, onto its own close button, which comes first in the panel; 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. The page beside the drawer is inert meanwhile, so neither a pointer nor a screen reader that ignores aria-modal reaches it; the drawer, its backdrop and a toast region stay reachable, and a click on the backdrop still closes the drawer.

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. That release deliberately leaves focus where the reader put it, rather than sending it to the toggle: the toggle is hidden at that width, and focusing a hidden element is a silent no-op that would drop the reader back on <body>.

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.
  • Put a skip link before the shell. The header and the sidebar come back on every page, and a keyboard reader tabs through all of them before reaching the content. <x-wirekit::skip-link> jumps to <x-wirekit::main id="main-content">, its default target (WCAG 2.4.1).
  • Keep help in the shell, not in a page. Contact details, a link to your help center or a chat button that your pages repeat belong in the header, the sidebar or the footer the shell shows on every page. There they stand in the same place on each of them, as WCAG 3.2.6 asks. Help written into the content of one page and into the header of another is in a different place on each.

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
--wk-shell-foot-h Floor height for every column's foot in this shell — see below
--wk-shell-height The height the shell treats as the window: its height with viewport, its minimum without. Unset by default — see below

A height other than the window's

A shell is as tall as the window: exactly, with viewport, and at least, without. Somewhere that is not a window, a preview box or an embedded frame, set --wk-shell-height on the shell or on anything around it, and the shell takes that height instead:

/* 1. A box of your own that holds a whole shell, such as a preview or an embedded frame */
.preview-box {
    --wk-shell-height: 30rem;
}

/* 2. Or relative to a container you have made a size container */
.preview-box--fluid {
    container-type: size;
    --wk-shell-height: 100cqh;
}

Left unset, the shell fills the window less any strip above the page. The property is the name to reach for rather than the shell's height utilities: those are written as arbitrary values, so their class names change whenever the expression does.

One line under two columns

A shell with a rail beside a sidebar has two feet, and the rule above each is meant to read as one line crossing the window. Each foot sizes itself to its own content, so two feet holding different things start at different heights and the line breaks by the difference.

Set the floor on the shell and every foot inside it reads the same number:

<x-wirekit::app-shell viewport style="--wk-shell-foot-h: 5.0625rem;">

Pick the height of the tallest foot — a rail holding two stacked controls needs more room than a sidebar holding one bar, and min-height cannot shrink the taller one to match.

Leave it unset and nothing changes: the floor is the shell-bar height, which is what a foot holding a single bar is anyway. It is only worth setting when two columns' feet differ.

CSS cannot take the maximum across two independent columns, which is why this is a number you supply rather than something the shell works out. The mechanism that could — subgrid — sits above WireKit's browser baseline, and below that baseline the fallback would be exactly the broken line this removes.

Updated in WireKit v2.67.0 (2026-10-15)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store