Skip to main content
WireKit
Copy for LLM

Stacked Shell

One bar across the top, the content below it, and no navigation column at all. Everything the application can do fits in a single row, which is the condition for this shape and also its limit: a row holds about seven items before it starts hiding things behind a menu, and a menu of sections is a column that has been folded up badly.

The bar spans the full width, and the content region below it is the only thing that scrolls. That is what separates the shape from an ordinary page with a header — a page scrolls the bar away with everything else.

Composed from: app-shell, avatar, breadcrumb, button, card, dropdown, footer, heading, icon, main, navbar, shell-bar, skip-link, stack, stat, stats, text.

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.

When the sections stop fitting in a row, the answer is a column: see Sidebar Shell, or Console Shell once there are several product areas rather than several pages.

Stacked Shell — one bar across the top, the trail under it

Milestones

The bar stays where it is. Only this region scrolls, which is what makes the shape a shell rather than a page with a header on top.

Blade Code

Save this as your application's root layout. Two common locations:

  • resources/views/components/layouts/app.blade.php — the Livewire v4 anonymous-component convention (default in the Livewire Starter Kit). Reference it from a Livewire component class via #[Layout('components.layouts.app')].
  • resources/views/layouts/app.blade.php — the traditional Blade-template path. Reference it from a route or controller via @extends('layouts.app').
{{-- 1. Outside the shell and before it, so a keyboard reader meets it first. --}}
<x-wirekit::skip-link />

{{-- 2. `viewport` fills the window. Without it this is a page with a header, and the bar
     scrolls away with the content. --}}
<x-wirekit::app-shell viewport>

    {{-- 3. Two children in one header slot: the navigation bar, and the trail under it.
         They stack, and the region below them takes the remaining height. --}}
    <x-slot:header>
        <x-wirekit::navbar>
            <x-slot:brand>
                <x-wirekit::avatar initials="AC" size="sm" />
                <x-wirekit::text weight="semibold">Acme</x-wirekit::text>
            </x-slot:brand>

            {{-- 4. `:active` is what emits aria-current="page". It is the only thing that
                 tells a screen reader which section it is in. --}}
            <x-wirekit::navbar.item href="{{ route('overview') }}" :active="request()->routeIs('overview')">Overview</x-wirekit::navbar.item>
            <x-wirekit::navbar.item href="{{ route('projects.index') }}" :active="request()->routeIs('projects.*')">Projects</x-wirekit::navbar.item>
            <x-wirekit::navbar.item href="{{ route('reports.index') }}" :active="request()->routeIs('reports.*')">Reports</x-wirekit::navbar.item>

            <x-slot:actions>
                <x-wirekit::button intent="neutral" surface="ghost" size="sm" aria-label="Notifications">
                    <x-wirekit::icon name="bell" size="sm" />
                </x-wirekit::button>

                {{-- 5. The trigger is a button wrapping the avatar, not the avatar itself:
                     the dropdown puts its ARIA on the inner interactive element. --}}
                <x-wirekit::dropdown placement="bottom-end">
                    <x-slot:trigger>
                        <x-wirekit::button intent="neutral" surface="ghost" size="sm" aria-label="Account menu">
                            <x-wirekit::avatar :initials="auth()->user()->initials()" size="sm" />
                        </x-wirekit::button>
                    </x-slot:trigger>
                    <x-wirekit::dropdown.item href="{{ route('profile') }}">Profile</x-wirekit::dropdown.item>
                    <x-wirekit::dropdown.separator />
                    <x-wirekit::dropdown.item href="{{ route('logout') }}" danger>Sign out</x-wirekit::dropdown.item>
                </x-wirekit::dropdown>
            </x-slot:actions>
        </x-wirekit::navbar>

        {{-- 6. The trail gets its own labeled bar, so it is a named landmark rather than
             loose text under the navigation. --}}
        <x-wirekit::shell-bar label="Page location">
            <x-wirekit::breadcrumb :items="$breadcrumbs ?? []" />
        </x-wirekit::shell-bar>
    </x-slot:header>

    {{-- 7. `id="main-content"` is the skip link's target, and setting it also gives the
         landmark tabindex="-1" so the jump moves focus rather than only scrolling. --}}
    <x-wirekit::main id="main-content">
        {{ $slot }}
    </x-wirekit::main>
</x-wirekit::app-shell>

Passing the trail

The bar is the same on every page; the trail is not. Hand it in from the page rather than building it inside the layout, so a page that has no trail simply passes nothing.

{{-- resources/views/livewire/projects/milestones.blade.php --}}
<x-layouts.app :breadcrumbs="[
    ['label' => 'Projects', 'href' => route('projects.index')],
    ['label' => $project->name, 'href' => route('projects.show', $project)],
    ['label' => 'Milestones'],
]">
    <x-wirekit::stack gap="md">
        <x-wirekit::heading level="1" size="lg">Milestones</x-wirekit::heading>
        {{ $milestones->links() }}
    </x-wirekit::stack>
</x-layouts.app>

Giving the content a width

main boxes its content at a readable measure by default and centers it under the full-width bar. That pairing is the whole reason the shape survives on a wide monitor: the bar's rule keeps the window's width because it is chrome, and the text does not, because nobody reads a line that is 2000 pixels long.

The bar's RULE and the bar's ITEMS are two different questions, and the preview below answers both. The rule runs edge to edge; the brand and the links sit over the same column as the content underneath, so a reader's eye follows one vertical line down the page instead of two. Give the bar the same container max as main — matching tiers is what makes the edges coincide.

Pass max="none" when the content genuinely wants the window — a table, a board, a canvas. Pass a narrower step when it is prose.

The rule keeps the window's width; the items follow the content

Working agreements

The bar above still runs edge to edge, and this column does not. On a wide monitor that difference is what keeps the page readable — the chrome belongs to the window, the prose belongs to the eye.

Everything at once

The previews above each hold one decision still. This one holds none of them still: the bar carries a brand, a navigation row and an action cluster with an account menu behind it; a second bar under it carries the trail; the content sits above a real page footer. It is the one to paste when you are starting an application rather than reading about one.

Start here and strip back Furnishing an empty shell means working out which slot each part belongs in. Starting from a full one means taking out the slots you have no content for, and what is left still stands up — the zones are independent of one another.

Stacked Shell — every zone filled

Milestones

Shipped
14
increased +3
In flight
5
Overdue
2
increased +1

Atlas 2.0

The bars stay where they are. Only this region scrolls, which is what makes the shape a shell rather than a page with a header on top.

© 2026 Acme Inc.

The row is the navigation, and it has a ceiling

  • One nav landmark, named by the navbar. The trail gets a second, named by the label on its bar — two landmarks of the same role need different names, or a screen reader lists them identically.
  • aria-current="page" comes from :active on the item. It is the only thing that tells a non-visual reader which section they are in; the visual treatment says nothing to them.
  • The navbar collapses to a menu below md and brings its own toggle with it. This shell has no navigation column, so it opens no drawer of its own and there is exactly one menu on the page.
  • The ceiling is about seven items. Past that the row starts hiding sections behind an overflow menu, and a hidden section is one a reader will not find. That is the signal to move to a column, not to make the row scroll.

Customization

  • The bar's width and the content's width are separate decisions, and Giving the content a width is the section that walks them. A full-bleed bar over a boxed column is the common shape and it is two props, not a wrapper.
  • The trail is optional and takes its own bar. Passing the trail covers what it needs; a breadcrumb crammed into the navigation row is the arrangement that looks fine at desktop and wraps into two lines on the first laptop.
  • The row's ceiling is a real limit, not a style preference — see The row is the navigation, and it has a ceiling. Past it, this is the wrong shell.

Production Considerations

  • A horizontal navigation row does not scale with the application. Every section added is a section every reader scans, and the row runs out of width before the product runs out of areas — planning for that at the start is cheaper than migrating shells later.
  • The active section comes from the server, for the same reason it does in every other shell: a first paint with nothing marked is a highlight that arrives late on every navigation.
  • Sticky bars cost their height on every screen, and on a phone in landscape that is a substantial share of it. Decide whether the bar sticks per breakpoint rather than globally.
  • The trail is generated from the route, not written by hand. A breadcrumb maintained separately from the routing is one that disagrees with the address bar within a release.

Accessibility

The reasoning lives in The row is the navigation, and it has a ceiling, and the shape is:

  • Two nav landmarks with different names — the row and the trail. Two of the same role sharing a name is what makes a screen reader list them identically.
  • aria-current="page" from :active on the item, which is the only signal a non-visual reader gets about where they are.
  • A skip link before the row, targeting main, so a keyboard reader is not required to tab the whole navigation on every page.
  • The overflow control, if the row has one, is a button with aria-expanded — a row that hides items behind a menu has to announce that the menu is there.
  • Sidebar Shell — the same application once the sections stop fitting in a row
  • Navbar — the bar itself, usable without a shell
  • Breadcrumb — the trail
  • App Shell — the primitive this page composes