Multi-Column Shell
Three columns. A rail on the far left for the product areas, a list of RECORDS beside it, and the record you picked filling the rest. What separates this from the sidebar shape is the middle column: it holds things, not destinations, and its contents change as you move around the application.
Each column scrolls on its own, which is what makes it a shell rather than a page layout. You can read to the bottom of a long message without the list of messages moving, and you can scroll the list without losing the message. A page layout cannot do that — one scrollbar belongs to the window, and everything on the page rides it.
Composed from: app-shell, app-rail, sidebar, 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.
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: under header-placement="content" the columns come
first in DOM order, so a skip link inside would be reached after all of them. --}}
<x-wirekit::skip-link />
<x-wirekit::app-shell viewport header-placement="content">
{{-- 2. The rail carries the product AREAS. Each item's `label` is its only accessible
name in tooltip mode, where the text is not drawn at all. --}}
<x-slot:rail>
<x-wirekit::app-rail label="Modules">
<x-slot:brand>
<x-wirekit::shell-bar padding="none" align="center">
<x-wirekit::avatar initials="AC" size="sm" />
</x-wirekit::shell-bar>
</x-slot:brand>
<x-wirekit::app-rail.item href="{{ route('mail.inbox') }}" icon="inbox" label="Inbox" :badge="$unread" :active="request()->routeIs('mail.inbox')" />
<x-wirekit::app-rail.item href="{{ route('mail.sent') }}" icon="send" label="Sent" :active="request()->routeIs('mail.sent')" />
<x-wirekit::app-rail.item href="{{ route('mail.archive') }}" icon="archive" label="Archive" :active="request()->routeIs('mail.archive')" />
<x-slot:footer>
<x-wirekit::app-rail.item href="{{ route('settings') }}" icon="settings" label="Settings" />
</x-slot:footer>
</x-wirekit::app-rail>
</x-slot:rail>
{{-- 3. The middle column holds RECORDS. `variant="flush"` keeps it on the shell's own
background rather than making it a panel inside a panel. --}}
<x-slot:sidebar>
<x-wirekit::sidebar variant="flush" label="Message list">
<x-slot:header>
<x-wirekit::shell-bar padding="sm" bleed>
<x-wirekit::text weight="semibold">Inbox</x-wirekit::text>
</x-wirekit::shell-bar>
</x-slot:header>
{{-- 4. `:active` is what emits aria-current="page" — the row is a link to a
record, so the contract is navigation. See the accessibility note below for
the case where it is not. --}}
@foreach($messages as $message)
<x-wirekit::sidebar.item :href="route('mail.show', $message)" :active="$message->is($selected)">
{{-- 5. The icon slot is aria-hidden, so this avatar needs no `alt`: the
sender's name is already in the label beside it. --}}
<x-slot:icon><x-wirekit::avatar :initials="$message->sender->initials()" size="xs" /></x-slot:icon>
{{-- 6. `as="span"` because the item renders its label inside a <span>,
and a block element there is invalid nesting. --}}
<x-wirekit::stack as="span" gap="none">
<x-wirekit::text as="span" weight="semibold">{{ $message->sender->name }}</x-wirekit::text>
<x-wirekit::text as="span" size="sm" variant="muted">{{ $message->subject }}</x-wirekit::text>
</x-wirekit::stack>
</x-wirekit::sidebar.item>
@endforeach
</x-wirekit::sidebar>
</x-slot:sidebar>
<x-slot:header>
<x-wirekit::shell-bar>
{{-- 7. Pinned slot. The default slot scrolls when the bar overflows, which on a
phone is immediately — and this is the control that opens the list. --}}
<x-slot:start>
<x-wirekit::sidebar.toggle class="lg:hidden" aria-label="Open message list" />
</x-slot:start>
<x-wirekit::heading level="1" size="md">{{ $selected->subject }}</x-wirekit::heading>
<x-slot:end>
<x-wirekit::button size="sm">Reply</x-wirekit::button>
</x-slot:end>
</x-wirekit::shell-bar>
</x-slot:header>
<x-wirekit::main max="none" id="main-content">
{{ $slot }}
</x-wirekit::main>
</x-wirekit::app-shell>
Wiring the list to Livewire
This is a topic of its own rather than a footnote to the fence above: the shell is static markup, and everything that makes the middle column feel live happens in the component class behind it.
<?php
// app/Livewire/Mail/Inbox.php
namespace App\Livewire\Mail;
use App\Models\Message;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('components.layouts.app')]
class Inbox extends Component
{
// 1. The selected record is public state, so the URL and the back button follow it.
public ?Message $selected = null;
// 2. Same name as the Blade above binds. A fence that names it differently gives a
// reader an undefined-variable error the moment they paste both halves.
public function select(Message $message): void
{
$this->selected = $message;
}
public function render()
{
return view('livewire.mail.inbox', [
// 3. Eager-load the sender: the list renders one avatar per row, and without
// this each row costs its own query.
'messages' => Message::with('sender')->latest()->get(),
'unread' => Message::whereNull('read_at')->count(),
]);
}
}
When the rail does not earn its column
A rail answers one question: which product area am I in. An application with a single area has no such question, and a rail there is a column of chrome that never changes — it costs width on every screen and tells the reader nothing they did not already know.
Drop it, and the list becomes the outermost column. Two props do that together and are easy to
confuse: :sidebar-inset="false" removes the gap the shell would otherwise leave around the
column, and variant="flush" removes the card the column would otherwise draw inside that
gap. One without the other leaves either a floating panel against the window edge or a
gapless card, and both look like a mistake.
If the rail earns its column but should not always be open, that is the expandable shape — see Rail Shell.
The dimensions the shell decides for you
| Decision | Where it lives |
|---|---|
| Width of the list column | --wk-sidebar-w, a CSS variable the sidebar reads with a 16rem fallback. A variable rather than a prop, so an application sets it once for every screen. |
| When the columns become a drawer | The lg breakpoint, owned by the shell. It is not configurable on purpose: the rail and the list travel as one panel below it, and two panels crossing at different widths land as a seam. |
| Which row looks selected | The :active prop on sidebar.item. The appearance is fixed — one muted background — so a row cannot be styled into meaning something the ARIA does not say. |
| Rail color | tone on the shell plus the --color-wk-rail-* roles, so a dark rail beside a light content column stays one decision rather than three. |
Everything else is theming rather than layout: see Theming.
The list column is navigation, and that is a decision
Every row in the middle column is a link to a record's own URL. That makes the column a nav
landmark, and the row marked :active emits aria-current="page" — which is exactly true
here, because following the row DID change the page.
It is not true for every two-column product, and the difference matters more than it
looks. If your selection stays on the client and the URL never changes, aria-current="page"
announces a page you are not on. That shape wants the WAI-ARIA listbox pattern instead —
role="listbox" over role="option" rows, with aria-selected and an arrow-key model — and
this shell does not provide it. Building it by hand is possible and is the honest answer
today; the capability is filed against the component rather than papered over here.
What you get without wiring anything:
- Two named landmarks, never one. The rail and the list column are both
nav, so each carries a distinctlabel— "Modules" and "Message list" above. Two landmarks of the same role with the same name are worse than one, because a screen reader lists them identically. - The detail pane is never a blank landmark. When nothing is selected, render the empty
state rather than an empty region: a reader who jumps to
mainand finds nothing has no way to tell an empty inbox from a broken page. - Tab moves between the columns and the focus ring is visible on every interactive element. There is no roving-focus model here and there should not be — these are links.
Below the breakpoint
The rail and the list 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">
Messages
</x-wirekit::button>
Related
- Console Shell — the same three columns when the middle one holds destinations rather than records
- Rail Shell — the rail on its own, expandable
- Sidebar — the list column itself
- App Shell — the primitive all of these compose