Console Shell
Three columns. A narrow rail of application areas on the outside, the selected area's own navigation beside it, the content to the right — and one horizontal rule running across all three at the same height, separating the workspace mark, the module name and the page's own navigation.
It is the shape an application takes once it has more than one product area. A single sidebar can answer "where in this area am I"; it cannot also answer "which area am I in" without becoming a list of lists.
Composed from: app-shell, app-rail, shell-bar, sidebar, 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.
A workspace switcher and nested navigation
The second column's head is a shell-bar like the others, so anything can go in it — including
a control. A workspace switcher there is the common case: the mark and the name of the thing
you are inside, with a way to change it.
Below it, <x-wirekit::sidebar.collapsible> nests a section. Its children carry a guide line
down their inline-start edge, which is what makes the nesting readable at a glance — indentation
alone is ambiguous the moment a label wraps, because the second line of a parent starts at the
same x as a child.
A group can also carry its own control. <x-wirekit::sidebar.group> takes an action slot —
the "add a team" plus beside a heading — and it sits BESIDE the heading rather than inside it,
because an interactive control nested in another one is unreachable by keyboard in practice.
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. `viewport` pins the shell to the screen so the columns scroll internally
rather than the page scrolling as one document. --}}
{{-- 2. `header-placement="content"` puts the top bar INSIDE the content column, which
is what lets both navigation columns run the full height beside it. --}}
<x-wirekit::app-shell viewport header-placement="content">
{{-- 3. The module rail. Its `brand` slot is the rail's segment of the top rule. --}}
<x-slot:rail>
<x-wirekit::app-rail>
<x-slot:brand>
<x-wirekit::shell-bar padding="none" align="center">
<x-wirekit::avatar :initials="auth()->user()->initials" size="sm" />
</x-wirekit::shell-bar>
</x-slot:brand>
{{-- 4. `label` is not decoration — in this mode nothing of it is drawn, so it
is the link's only accessible name and the tooltip's text. --}}
<x-wirekit::app-rail.item href="{{ route('overview') }}" icon="home" label="Overview" />
<x-wirekit::app-rail.item href="{{ route('payments') }}" icon="credit-card" label="Payments" />
{{-- 5. The bottom cluster stays put while the module list scrolls. --}}
<x-slot:footer>
<x-wirekit::app-rail.item href="{{ route('profile') }}" icon="user" label="Account" />
</x-slot:footer>
</x-wirekit::app-rail>
</x-slot:rail>
{{-- 6. The module's own navigation. `variant="flush"` makes it a full-bleed column
rather than a card, and its `header` slot is the second rule segment. --}}
<x-slot:sidebar>
<x-wirekit::sidebar variant="flush" label="Payments navigation">
<x-slot:header>
<x-wirekit::shell-bar padding="sm" bleed>
<x-wirekit::text weight="medium">Payments</x-wirekit::text>
</x-wirekit::shell-bar>
</x-slot:header>
<x-wirekit::sidebar.item href="{{ route('terminals') }}">Terminals</x-wirekit::sidebar.item>
<x-wirekit::sidebar.item href="{{ route('purchases') }}">Purchases</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
</x-slot:sidebar>
{{-- 7. The third rule segment, in the content column. --}}
<x-slot:header>
<x-wirekit::shell-bar label="Terminal sections">
{{-- The handle for the drawer the two navigation columns become below `lg`.
`start` keeps it pinned while the sections beside it scroll. --}}
<x-slot:start>
<x-wirekit::sidebar.toggle class="lg:hidden" aria-label="Open navigation" />
</x-slot:start>
<x-wirekit::navbar.item href="#" :active="true">Transactions</x-wirekit::navbar.item>
<x-slot:end>
<x-wirekit::button size="sm">Create payment</x-wirekit::button>
</x-slot:end>
</x-wirekit::shell-bar>
</x-slot:header>
{{-- 8. `max="none"` fills the available width. Drop it — or pass a tier like
`max="xl"` — to box the content instead. --}}
<x-wirekit::main max="none">
{{ $slot }}
</x-wirekit::main>
</x-wirekit::app-shell>
The breadcrumb already carries its structured data
The trail in the content column's head is a <x-wirekit::breadcrumb>, and it emits schema.org
BreadcrumbList JSON-LD on its own — there is nothing to switch on. It renders nothing visible,
which is why it is easy to assume it is missing:
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Create", "item": "/create" },
{ "@type": "ListItem", "position": 2, "name": "Select card" }
]
}
Note the last entry: it is named but carries no item. That is the current page, and Google's
guidance is that it is included in the trail without a URL — a final entry that links somewhere
reads as pointing away from the page it describes.
One caveat for this exact layout. A console usually has room for a breadcrumb in the top bar
and a heading in the content, and it is tempting to put a second trail in the body. Pass
schema="false" on that one: a page must carry exactly one BreadcrumbList, and two of them
compete rather than combine.
Where the account goes
The signed-in person's name belongs to the mark that represents them, not to a column beside it. In the rail that means the avatar is the trigger and the name is what the menu it opens carries — so clicking the one thing that looks like "you" produces everything about you, and the navigation column stays a list of destinations.
Wrap the footer's account entry in a <x-wirekit::dropdown> and pass the person's name as the
item's label: it is the accessible name of the trigger either way, and in a wide rail it is
also what is drawn.
The rule that runs across the columns
Each column's head is a <x-wirekit::shell-bar>. That is the whole mechanism: every bar reads
--size-wk-shell-bar for its height, so the three segments cannot drift apart the way three
hand-aligned headers would the first time one column gets a longer title.
The three bars use different padding values on purpose. The rail's is none — its content
is a centered mark, not text on a spine. The second column's is sm, matching the narrower
tier its own items use. The content column's is the default lg, the page-edge spine, so a
title in the bar lines up with the first paragraph below it.
Full width or boxed
<x-wirekit::main> boxes its content to a container width by default, which is right for a
document and wrong for a console — a transaction table wants the screen. max="none" fills
the available width; any other tier boxes it.
Below the breakpoint
The rail and the module navigation travel as ONE off-canvas panel rather than sliding independently — two panels moving at different offsets land as a seam. The shell owns the flag, so any control inside it can open the drawer:
{{-- 1. `sidebarOpen` lives on the shell root, so a trigger anywhere inside it can flip it. --}}
{{-- 2. `lg:hidden` because above the breakpoint both columns are in flow and there is
nothing to open. --}}
<x-wirekit::button x-on:click="sidebarOpen = true" class="lg:hidden">
Menu
</x-wirekit::button>