Skip to main content
WireKit
Copy for LLM

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-rail, app-shell, avatar, badge, button, dropdown, footer, heading, icon, main, row, shell-bar, sidebar, skip-link, stack, text, tooltip.

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.

Multi-Column Shell — rail, message list, message

Quarterly numbers are in

Alice Johnson

Alice Johnson

Replied

Revenue closed 12% above plan, and the churn figure came in under two percent for the first time this year. The full breakdown is attached; the short version is that the enterprise tier carried the quarter.

Everything at once

The other previews on this page 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.

Multi-Column Shell — every zone filled

Quarterly numbers are in

AJ

Alice Johnson

Revenue closed 12% above plan, and the churn figure came in under the forecast. The short version is that the enterprise tier carried the quarter.

© 2026 Acme Inc.

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" intent="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.

Two columns — the list is the outermost column

Card declined at checkout

Mia Bergstrom

Mia Bergstrom

Waiting on us

The card is declined at the final step, but the bank shows no attempt. It happens on two different cards, so it is unlikely to be the card itself.

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 list column collapses, and the reader decides when

Quarterly numbers are in

Collapse the list from the control in its head. The reading column takes the space the list gives up.

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 list stays put, and the detail column narrows instead

Quarterly numbers are in

The list on the left keeps the width it was given. Only the column on the right answers its own control.

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 distinct label — "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 main and 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>

Customization

The decisions this shell asks of you have their own sections: When the rail does not earn its column, Deciding which column gives way, The dimensions the shell decides for you and Below the breakpoint. Beyond them:

  • The list column's width is a shell dimension, not a class on this page. It is the one measurement three columns have to agree on, which is why the shell owns it.
  • The selection mode is an API decision, not a style — see The list column is navigation, and that is a decision. Getting it wrong announces a page the reader is not on.

Production Considerations

  • Three columns are three queries with three rates of change. Fetching them together makes the slowest one the page's speed; the rail changes rarely, the list changes often, the record changes on selection.
  • The list needs a cursor once it is long. Offset pagination re-reads everything it skips, and a mail or queue list is exactly the surface that grows without anybody deciding to.
  • Selection has to survive a reload, which means it belongs in the URL. That is also what makes a row shareable and the back button work — and it is the condition under which aria-current="page" is the honest choice rather than the listbox pattern.
  • Rendering the record column is a separate authorization check. The list already filtered to what the reader may see; the detail request arrives on its own and carries none of that.
  • Below the breakpoint only one column is on screen, so the back affordance is not decoration — without it a phone reader reaches a record and cannot get back to the list.

Accessibility

The full reasoning is in The list column is navigation, and that is a decision. In short:

  • Two nav landmarks with different names — the module rail and the list column.
  • aria-current="page" on the selected row when following it changed the page, and the WAI-ARIA listbox pattern instead when the selection stays on the client. The sidebar ships that pattern under mode="selection"; announcing a page the reader is not on is the failure the choice avoids.
  • Every row is a real link or a real button, so Tab reaches it and Enter operates it with no JavaScript involved.
  • Each column scrolls on its own, and every one is keyboard-reachable through the focusable controls inside it rather than through the scrolling itself.
  • The back affordance below the breakpoint carries a descriptive name — "Back to messages", not "Back", which tells a screen-reader user nothing about where they are going.
  • 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