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.
Everything at once
The previews above each hold one decision still. This one holds none of them still: rail, list, reading column and a collapsible detail column, with every head and foot occupied and a page footer under the content. 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.
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 class="lg:hidden">
<x-wirekit::sidebar.toggle 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.
Deciding which column gives way
Three columns and one window: something has to yield when the reader wants more room, and the shell does not decide which. The two previews here are the two answers, and they are worth seeing side by side because the markup difference between them is one attribute in a different place.
The list gives way. collapsible on the list column puts a control in its head, so the
reader sets its width themselves — wide while they are scanning subjects, narrow once they are
reading one. persist remembers the choice, so it survives a reload rather than resetting to
whatever the server guessed.
The detail column gives way. Here the list keeps its width and a fourth column — the one
holding the record's metadata — is the one that narrows. It is an ordinary sidebar standing
inside the content region rather than in the shell's own slot, which is what lets it sit on the
far side of the reading column. The list column beside it has no collapsible at all, so it
does not move.
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, and the sidebar carries it — pass
mode="selection":
{{-- 1. The column becomes one control holding choices, not a set of destinations. --}}
<x-wirekit::sidebar mode="selection" :selected="$selectedId" label="Records">
@foreach($records as $record)
{{-- 2. `value` is the row's identity. No `href`: a link here would put the row
back in the tab order and make Enter navigate instead of choose. --}}
<x-wirekit::sidebar.item
:value="$record->id"
wire:click="select({{ $record->id }})"
>{{ $record->title }}</x-wirekit::sidebar.item>
@endforeach
</x-wirekit::sidebar>
What changes is the whole contract, not one attribute. The container becomes a
role="listbox" with a single tab stop; the rows become role="option" and are marked
aria-selected; the arrows move an aria-activedescendant marker without moving focus;
and no aria-current is emitted at all, which is the point rather than an omission.
Pick one contract per column, not both
A column cannot honestly be a nav landmark and a listbox at once. Announcing a page
you are on and a value you have picked are two different claims, and a column making both
is wrong about one of them.
The rule of thumb is the URL: if following a row changes it, the column is navigation. If
the choice stays on the client, it is a selection. mode="selection" and collapsible are
refused together for the same reason — a collapsed rail hides the labels a choice needs,
so the column would still announce a selection nobody can read.
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