Skip to main content
WireKit

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, navbar, shell-bar, main.

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" class="h-4 w-4" />
                </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', 'url' => route('projects.index')],
    ['label' => $project->name, 'url' => 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 keeps the window's width because it is chrome, and the text does not, because nobody reads a line that is 2000 pixels long.

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 bar keeps the window's width; the content does not

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.

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.
  • 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