---
title: Multi-Column Shell
description: A module rail, a scrollable list of records beside it, and the selected record filling the rest — the shell shape behind mail, queues and chat.
visibility: guest
draft: false
category: application-shells
tags: [application-shells, shell, rail, sidebar, list]
dependencies: [app-rail, app-shell, avatar, badge, button, dropdown, footer, heading, icon, main, row, shell-bar, sidebar, skip-link, stack, text, tooltip]
responsive: true
dark_compatible: true
---

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

:::preview{title="Multi-Column Shell — rail, message list, message" wide bleed height="30rem"}
<x-wirekit::app-shell viewport header-placement="content">
    <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>

            {{-- `label` is not decoration here: in tooltip mode nothing of it is drawn, so
                 it is the link's ONLY accessible name. --}}
            <x-wirekit::app-rail.item href="#" icon="inbox" label="Inbox" badge="4" active />
            <x-wirekit::app-rail.item href="#" icon="send" label="Sent" />
            <x-wirekit::app-rail.item href="#" icon="archive" label="Archive" />
            <x-wirekit::app-rail.item href="#" icon="tag" label="Labels" />

            <x-slot:footer>
                <x-wirekit::app-rail.item href="#" icon="search" label="Search" />
                <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
            </x-slot:footer>
        </x-wirekit::app-rail>
    </x-slot:rail>

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

            <x-wirekit::sidebar.item href="#" active>
                {{-- The icon slot is aria-hidden, so this avatar carries no `alt` — the name
                     is already in the label beside it. --}}
                <x-slot:icon><x-wirekit::avatar initials="AJ" size="xs" /></x-slot:icon>
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Alice Johnson</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Quarterly numbers are in</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-slot:icon><x-wirekit::avatar initials="RM" size="xs" /></x-slot:icon>
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Ravi Mehta</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Re: onboarding checklist</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-slot:icon><x-wirekit::avatar initials="SK" size="xs" /></x-slot:icon>
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Sofia Karlsson</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Contract draft attached</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-slot:icon><x-wirekit::avatar initials="TN" size="xs" /></x-slot:icon>
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Tomas Novak</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Invoice 4471 is overdue</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    <x-slot:header>
        <x-wirekit::shell-bar>
            <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">Quarterly numbers are in</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">
        <x-wirekit::stack gap="md">
            {{-- `wrap` because `row` does not wrap by default, and a 393px viewport would
                 otherwise push the document sideways. --}}
            <x-wirekit::row gap="sm" justify="between" wrap>
                <x-wirekit::row gap="sm">
                    <x-wirekit::avatar initials="AJ" size="sm" alt="Alice Johnson" />
                    <x-wirekit::text weight="semibold">Alice Johnson</x-wirekit::text>
                </x-wirekit::row>
                <x-wirekit::badge intent="success">Replied</x-wirekit::badge>
            </x-wirekit::row>

            <x-wirekit::text>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.</x-wirekit::text>
        </x-wirekit::stack>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

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

::: tip 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.
:::

:::preview{title="Multi-Column Shell — every zone filled" wide bleed height="34rem"}
<x-wirekit::app-shell viewport header-placement="content" :sidebar-inset="false" style="--wk-shell-foot-h: 5.0625rem;">
    <x-slot:rail>
        <x-wirekit::app-rail>
            <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.group label="Mail">
                <x-wirekit::app-rail.item href="#" icon="inbox" label="Inbox" :active="true" badge="4" />
                <x-wirekit::app-rail.item href="#" icon="send" label="Sent" />
                <x-wirekit::app-rail.item href="#" icon="archive" label="Archive" />
            </x-wirekit::app-rail.group>

            <x-wirekit::app-rail.group label="Filing">
                <x-wirekit::app-rail.item href="#" icon="tag" label="Labels" />
            </x-wirekit::app-rail.group>

            <x-slot:footer>
                <x-wirekit::app-rail.item href="#" icon="search" label="Search" />
                <x-wirekit::dropdown placement="right-end">
                    <x-slot:trigger>
                        <x-wirekit::app-rail.item as="button" icon="user" label="Dana Ortiz" />
                    </x-slot:trigger>
                    <x-wirekit::dropdown.item href="#">Profile</x-wirekit::dropdown.item>
                    <x-wirekit::dropdown.item href="#">Preferences</x-wirekit::dropdown.item>
                    <x-wirekit::dropdown.separator />
                    <x-wirekit::dropdown.item href="#" danger>Sign out</x-wirekit::dropdown.item>
                </x-wirekit::dropdown>
            </x-slot:footer>
        </x-wirekit::app-rail>
    </x-slot:rail>

    <x-slot:sidebar>
        <x-wirekit::sidebar variant="flush" collapsible persist="wk-docs-multi-column-full-list" label="Message list">
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::text weight="semibold" class="wk-rail-hide">Inbox</x-wirekit::text>
                    <x-slot:end>
                        <x-wirekit::badge size="sm" tooltip="1 unread of 4 messages">1/4</x-wirekit::badge>
                    </x-slot:end>
                </x-wirekit::shell-bar>
            </x-slot:header>

            <x-wirekit::sidebar.item href="#" icon="envelope" active>Quarterly numbers are in</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Re: onboarding checklist</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Contract draft attached</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Invoice 4471 is overdue</x-wirekit::sidebar.item>

            <x-slot:footer>
                <x-wirekit::shell-bar padding="sm" bleed rule="top">
                    {{-- The words are the relative form because that is what a reader wants at a
                         glance; the tooltip carries the clock time to the second, because
                         "just now" stops being an answer the moment somebody has to know
                         whether a sync actually happened. Rendered server-side, so it is the
                         time this page was built rather than a placeholder. --}}
                    <x-wirekit::tooltip text="Last synced at {{ now()->format('H:i:s') }}">
                        <x-wirekit::text size="sm" intent="muted" class="wk-rail-hide">Synced just now</x-wirekit::text>
                    </x-wirekit::tooltip>
                </x-wirekit::shell-bar>
            </x-slot:footer>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    <x-slot:header>
        <x-wirekit::shell-bar>
            <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">Quarterly numbers are in</x-wirekit::heading>

            <x-slot:end>
                <x-wirekit::button size="sm" intent="neutral" surface="ghost">Archive</x-wirekit::button>
                <x-wirekit::button size="sm">Reply</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>

    {{-- `padding="none"` because a column that sits at the shell's edge has to REACH
         that edge. With the default `lg` padding the detail panel would sit inset from
         the top, the bottom and the right, so its border and its header rule would stop
         short and read as a misalignment rather than as a margin. The reading column
         keeps its breathing room by carrying the padding itself, which is where it
         belongs: it is the text that wants a gutter, not the panel. --}}
    <x-wirekit::main max="none" padding="none">
        <x-wirekit::row gap="none" align="stretch" style="height: 100%;">
            <x-wirekit::stack gap="md" style="flex: 1 1 auto; min-width: 0; padding: var(--space-wk-lg);">
                <x-wirekit::row gap="sm">
                    <x-wirekit::avatar initials="AJ" size="sm" />
                    <x-wirekit::text weight="semibold">Alice Johnson</x-wirekit::text>
                </x-wirekit::row>
                <x-wirekit::text intent="muted">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.</x-wirekit::text>

                <x-wirekit::footer max="full">
                    <x-wirekit::text size="sm" intent="muted">&copy; 2026 Acme Inc.</x-wirekit::text>
                </x-wirekit::footer>
            </x-wirekit::stack>

            <x-wirekit::sidebar variant="flush" collapsible side="end" toggle="start" persist="wk-docs-multi-column-full-detail" label="Message details">
                <x-slot:header>
                    <x-wirekit::shell-bar padding="sm" bleed>
                        {{-- Deliberately WITHOUT `wk-rail-hide`, unlike the word beside it: the
                             word is what has no room once the column folds, and dropping it left
                             the header band empty. The icon is what still fits, so it says what
                             the column is in both states. Its tooltip carries the same wording
                             the heading does, and `aria-label` gives it that name for a reader
                             who never hovers. --}}
                        <x-wirekit::tooltip text="Message details">
                            <x-wirekit::icon name="info" size="sm" style="color: var(--color-wk-text-muted); flex-shrink: 0;" aria-label="Message details" />
                        </x-wirekit::tooltip>
                        <x-wirekit::text weight="semibold" class="wk-rail-hide">Details</x-wirekit::text>
                    </x-wirekit::shell-bar>
                </x-slot:header>

                <x-wirekit::sidebar.item href="#" icon="user">Alice Johnson</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="tag">Finance</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="file-text">2 attachments</x-wirekit::sidebar.item>
            </x-wirekit::sidebar>
        </x-wirekit::row>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

## 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')`.

```blade
{{-- 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
<?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.

:::preview{title="Two columns — the list is the outermost column" wide bleed height="26rem"}
<x-wirekit::app-shell viewport header-placement="content" :sidebar-inset="false">
    <x-slot:sidebar>
        <x-wirekit::sidebar variant="flush" label="Ticket list">
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::text weight="semibold">Open tickets</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:header>

            <x-wirekit::sidebar.item href="#" active>
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Card declined at checkout</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Opened 2 hours ago</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Export finishes but the file is empty</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Opened yesterday</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Invite email never arrives</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Opened 3 days ago</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>

            <x-wirekit::sidebar.item href="#">
                <x-wirekit::stack as="span" gap="none">
                    <x-wirekit::text as="span" weight="semibold">Timezone is wrong on the report</x-wirekit::text>
                    <x-wirekit::text as="span" size="sm" intent="muted">Opened last week</x-wirekit::text>
                </x-wirekit::stack>
            </x-wirekit::sidebar.item>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    <x-slot:header>
        <x-wirekit::shell-bar>
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open ticket list" />
            </x-slot:start>

            <x-wirekit::heading level="1" size="md">Card declined at checkout</x-wirekit::heading>
        </x-wirekit::shell-bar>
    </x-slot:header>

    <x-wirekit::main max="none">
        <x-wirekit::stack gap="md">
            <x-wirekit::row gap="sm" justify="between" wrap>
                <x-wirekit::row gap="sm">
                    <x-wirekit::avatar initials="MB" size="sm" alt="Mia Bergstrom" />
                    <x-wirekit::text weight="semibold">Mia Bergstrom</x-wirekit::text>
                </x-wirekit::row>
                <x-wirekit::badge intent="warning">Waiting on us</x-wirekit::badge>
            </x-wirekit::row>

            <x-wirekit::text>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.</x-wirekit::text>
        </x-wirekit::stack>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

If the rail earns its column but should not always be open, that is the expandable shape —
see [Rail Shell](/blueprints/application-shells/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.

:::preview{title="The list column collapses, and the reader decides when" wide bleed height="28rem"}
<x-wirekit::app-shell viewport header-placement="content" :sidebar-inset="false">
    <x-slot:rail>
        <x-wirekit::app-rail>
            <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="#" icon="inbox" label="Inbox" :active="true" badge="4" />
            <x-wirekit::app-rail.item href="#" icon="send" label="Sent" />
            <x-wirekit::app-rail.item href="#" icon="archive" label="Archive" />
        </x-wirekit::app-rail>
    </x-slot:rail>

    <x-slot:sidebar>
        <x-wirekit::sidebar variant="flush" collapsible persist="wk-docs-multi-column-list" label="Message list">
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::text weight="semibold" class="wk-rail-hide">Inbox</x-wirekit::text>
                    <x-slot:end>
                        <x-wirekit::badge size="sm" tooltip="1 unread of 4 messages">1/4</x-wirekit::badge>
                    </x-slot:end>
                </x-wirekit::shell-bar>
            </x-slot:header>

            <x-wirekit::sidebar.item href="#" icon="envelope" active>Quarterly numbers are in</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Re: onboarding checklist</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Contract draft attached</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Invoice 4471 is overdue</x-wirekit::sidebar.item>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    <x-slot:header>
        <x-wirekit::shell-bar>
            <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">Quarterly numbers are in</x-wirekit::heading>
        </x-wirekit::shell-bar>
    </x-slot:header>

    <x-wirekit::main max="none">
        <x-wirekit::stack gap="md">
            <x-wirekit::text intent="muted">Collapse the list from the control in its head. The reading column takes the space the list gives up.</x-wirekit::text>
        </x-wirekit::stack>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

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

:::preview{title="The list stays put, and the detail column narrows instead" wide bleed height="28rem"}
<x-wirekit::app-shell viewport header-placement="content" :sidebar-inset="false">
    <x-slot:rail>
        <x-wirekit::app-rail>
            <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="#" icon="inbox" label="Inbox" :active="true" badge="4" />
            <x-wirekit::app-rail.item href="#" icon="send" label="Sent" />
            <x-wirekit::app-rail.item href="#" icon="archive" label="Archive" />
        </x-wirekit::app-rail>
    </x-slot:rail>

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

            <x-wirekit::sidebar.item href="#" icon="envelope" active>Quarterly numbers are in</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Re: onboarding checklist</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" icon="envelope">Contract draft attached</x-wirekit::sidebar.item>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    <x-slot:header>
        <x-wirekit::shell-bar>
            <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">Quarterly numbers are in</x-wirekit::heading>
        </x-wirekit::shell-bar>
    </x-slot:header>

    {{-- `padding="none"` because a column that sits at the shell's edge has to REACH
         that edge. With the default `lg` padding the detail panel would sit inset from
         the top, the bottom and the right, so its border and its header rule would stop
         short and read as a misalignment rather than as a margin. The reading column
         keeps its breathing room by carrying the padding itself, which is where it
         belongs: it is the text that wants a gutter, not the panel. --}}
    <x-wirekit::main max="none" padding="none">
        <x-wirekit::row gap="none" align="stretch" style="height: 100%;">
            <x-wirekit::stack gap="md" style="flex: 1 1 auto; min-width: 0; padding: var(--space-wk-lg);">
                <x-wirekit::text intent="muted">The list on the left keeps the width it was given. Only the column on the right answers its own control.</x-wirekit::text>
            </x-wirekit::stack>

            <x-wirekit::sidebar variant="flush" collapsible side="end" toggle="start" persist="wk-docs-multi-column-detail" label="Message details">
                <x-slot:header>
                    <x-wirekit::shell-bar padding="sm" bleed>
                        {{-- Deliberately WITHOUT `wk-rail-hide`, unlike the word beside it: the
                             word is what has no room once the column folds, and dropping it left
                             the header band empty. The icon is what still fits, so it says what
                             the column is in both states. Its tooltip carries the same wording
                             the heading does, and `aria-label` gives it that name for a reader
                             who never hovers. --}}
                        <x-wirekit::tooltip text="Message details">
                            <x-wirekit::icon name="info" size="sm" style="color: var(--color-wk-text-muted); flex-shrink: 0;" aria-label="Message details" />
                        </x-wirekit::tooltip>
                        <x-wirekit::text weight="semibold" class="wk-rail-hide">Details</x-wirekit::text>
                    </x-wirekit::shell-bar>
                </x-slot:header>

                <x-wirekit::sidebar.item href="#" icon="user">Alice Johnson</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="tag">Finance</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="file-text">2 attachments</x-wirekit::sidebar.item>
            </x-wirekit::sidebar>
        </x-wirekit::row>
    </x-wirekit::main>
</x-wirekit::app-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](/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"`:

```blade
{{-- 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.

::: warning 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:

```blade
{{-- 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](#when-the-rail-does-not-earn-its-column),
[Deciding which column gives way](#deciding-which-column-gives-way),
[The dimensions the shell decides for you](#the-dimensions-the-shell-decides-for-you) and
[Below the breakpoint](#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](#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](#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.

## Related

- [Console Shell](/blueprints/application-shells/console-shell) — the same three columns when the middle one holds destinations rather than records
- [Rail Shell](/blueprints/application-shells/rail-shell) — the rail on its own, expandable
- [Sidebar](/components/sidebar) — the list column itself
- [App Shell](/components/app-shell) — the primitive all of these compose
