---
title: Console Shell
description: A module rail, the module's own navigation beside it, and one horizontal rule across all three columns — the shape modern admin consoles converged on.
visibility: guest
draft: false
category: application-shells
tags: [application-shells, shell, rail, sidebar, console]
dependencies: [app-rail, app-shell, avatar, badge, breadcrumb, button, card, dropdown, footer, heading, icon, main, navbar, shell-bar, sidebar, stack, stat, stats, text, tooltip]
responsive: true
dark_compatible: true
---

# 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-rail`, `app-shell`, `avatar`, `badge`, `breadcrumb`, `button`, `card`, `dropdown`, `footer`, `heading`, `icon`, `main`, `navbar`, `shell-bar`, `sidebar`, `stack`, `stat`, `stats`, `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="Console Shell — rail, module navigation, content" wide bleed height="30rem"}
<x-wirekit::app-shell viewport header-placement="content">
    <x-slot:rail>
        <x-wirekit::app-rail>
            <x-slot:brand>
                <x-wirekit::shell-bar padding="none" align="center">
                    <x-wirekit::avatar initials="SW" size="sm" />
                </x-wirekit::shell-bar>
            </x-slot:brand>
            <x-wirekit::app-rail.item href="#" icon="home" label="Overview" />
            <x-wirekit::app-rail.item href="#" icon="bell" label="Notifications" badge="2" />
            <x-wirekit::app-rail.group :separated="true">
                <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Analytics" />
                <x-wirekit::app-rail.item href="#" icon="credit-card" label="Payments" :active="true" />
                <x-wirekit::app-rail.item href="#" icon="file-text" label="Invoices" />
                <x-wirekit::app-rail.item href="#" icon="shield" label="Risk" />
            </x-wirekit::app-rail.group>
            <x-slot:footer>
                <x-wirekit::app-rail.item href="#" icon="search" label="Search" />
                {{-- The account MENU, opened from the avatar. The name belongs to the mark it
                     stands beside, not to a column two places away — see the prose below. --}}
                <x-wirekit::dropdown placement="right-end">
                    <x-slot:trigger>
                        <x-wirekit::app-rail.item as="button" icon="user" label="Sam Weber" />
                    </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="#">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" 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="#" :active="true">Terminals</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#">Purchases</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#" target="_blank">Settings</x-wirekit::sidebar.item>

        </x-wirekit::sidebar>
    </x-slot:sidebar>
    <x-slot:header>
        <x-wirekit::shell-bar label="Terminal sections">
            <x-slot:start class="lg:hidden">
                {{-- Below `lg` the rail and the navigation beside it travel off-canvas, so the
                     bar owes them a handle. It sits in `start` rather than in the default slot
                     because that one scrolls: on a phone this bar overflows immediately, and a
                     hamburger that scrolls away is the same as no hamburger. --}}
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::navbar.item href="#" :active="true">Transactions</x-wirekit::navbar.item>
            <x-wirekit::navbar.item href="#">Terminal info</x-wirekit::navbar.item>
            <x-wirekit::navbar.item href="#">Settings</x-wirekit::navbar.item>
            {{-- One action, not two. The trailing cluster keeps its size by contract, so on a
                 phone every pixel it takes comes out of the strip beside it, and two buttons
                 leave the sections room for a few letters. What a bar can afford is a
                 question about the narrowest screen it will meet. --}}
            <x-slot:end>
                <x-wirekit::button size="sm">Create payment</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>
    <x-wirekit::main max="none">
        <x-wirekit::stack gap="lg">
            <x-wirekit::heading level="1" size="lg">Terminal A920 Pro 1</x-wirekit::heading>
            <x-wirekit::stats cols="4">
                <x-wirekit::stat label="Status" value="Active" />
                <x-wirekit::stat label="Network" value="Online" />
                <x-wirekit::stat label="Battery" value="72%" />
                <x-wirekit::stat label="Location" value="Berlin, DE" />
            </x-wirekit::stats>
        </x-wirekit::stack>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

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

:::preview{title="Console Shell — a workspace switcher over nested navigation" wide bleed height="26rem"}
<x-wirekit::app-shell viewport header-placement="content">
    <x-slot:rail>
        <x-wirekit::app-rail indicator="edge">
            <x-slot:brand>
                <x-wirekit::shell-bar padding="none" align="center">
                    <x-wirekit::avatar initials="SR" size="sm" />
                </x-wirekit::shell-bar>
            </x-slot:brand>
            <x-wirekit::app-rail.item href="#" icon="home" label="Create" :active="true" />
            <x-wirekit::app-rail.item href="#" icon="folders" label="Stock" />
            <x-wirekit::app-rail.item href="#" icon="calendar" label="Calendar" />
            <x-slot:footer>
                <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="Workspace navigation">
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::dropdown>
                        <x-slot:trigger>
                            <x-wirekit::button size="sm" intent="neutral" surface="ghost">
                                <x-wirekit::avatar initials="S" size="xs" />
                                Southern Rescue
                            </x-wirekit::button>
                        </x-slot:trigger>
                        <x-wirekit::dropdown.item href="#">Southern Rescue</x-wirekit::dropdown.item>
                        <x-wirekit::dropdown.item href="#">Northern Depot</x-wirekit::dropdown.item>
                    </x-wirekit::dropdown>
                </x-wirekit::shell-bar>
            </x-slot:header>
            <x-wirekit::sidebar.item href="#" :active="true">Create</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.item href="#">Stock</x-wirekit::sidebar.item>
            <x-wirekit::sidebar.collapsible label="Teams" icon="users" :open="true">
                <x-wirekit::sidebar.item href="#">Charlie</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#">Bravo</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#">Delta</x-wirekit::sidebar.item>
            </x-wirekit::sidebar.collapsible>
        </x-wirekit::sidebar>
    </x-slot:sidebar>
    <x-slot:header>
        <x-wirekit::shell-bar label="Card sections">
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::breadcrumb schema="false" :items="[
                ['label' => 'Create', 'href' => '#'],
                ['label' => 'Select card'],
            ]" />
        </x-wirekit::shell-bar>
    </x-slot:header>
    <x-wirekit::main max="none">
        <x-wirekit::text size="sm" intent="muted">Pick a card to continue.</x-wirekit::text>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

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.

## Everything at once

A console shell has more zones than any other shape in this family — two navigation columns,
three bars on one rule, and a foot for each column. The two previews above each hold one of
its decisions still; this one holds none of them still. It is the shell with every zone
occupied, and 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="Console Shell — every zone filled" wide bleed height="34rem"}
<x-wirekit::app-shell viewport header-placement="content" 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="SW" size="sm" />
                </x-wirekit::shell-bar>
            </x-slot:brand>

            <x-wirekit::app-rail.item href="#" icon="home" label="Overview" />
            <x-wirekit::app-rail.item href="#" icon="bell" label="Notifications" badge="2" />

            <x-wirekit::app-rail.group :separated="true">
                <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Analytics" />
                <x-wirekit::app-rail.item href="#" icon="credit-card" label="Payments" :active="true" />
                <x-wirekit::app-rail.item href="#" icon="file-text" label="Invoices" />
                <x-wirekit::app-rail.item href="#" icon="shield" label="Risk" />
            </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="Sam Weber" />
                    </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-console-shell-full" label="Payments navigation">
            <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
                         this band empty over a stack of icons. The icon is what still fits,
                         so it says what the column is in both states — with its name on
                         hover, and an `aria-label` for a reader who never hovers. --}}
                    <x-wirekit::tooltip text="Payments navigation">
                        <x-wirekit::icon name="credit-card" size="sm" style="color: var(--color-wk-text-muted); flex-shrink: 0;" aria-label="Payments navigation" />
                    </x-wirekit::tooltip>
                    <x-wirekit::text weight="medium" class="wk-rail-hide">Payments</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:header>

            <x-wirekit::sidebar.group label="Money in">
                <x-wirekit::sidebar.item href="#" icon="chart-bar" active>Transactions</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="bell" badge="3">Disputes</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="#" icon="send">Payouts</x-wirekit::sidebar.item>
            </x-wirekit::sidebar.group>

            <x-wirekit::sidebar.group label="Configuration">
                <x-wirekit::sidebar.collapsible label="Methods" :open="true">
                    <x-wirekit::sidebar.item href="#" icon="cube">Cards</x-wirekit::sidebar.item>
                    <x-wirekit::sidebar.item href="#" icon="file-text">Bank transfer</x-wirekit::sidebar.item>
                    <x-wirekit::sidebar.item href="#" icon="tag">Direct debit</x-wirekit::sidebar.item>
                </x-wirekit::sidebar.collapsible>
                <x-wirekit::sidebar.item href="#" icon="settings">Webhooks</x-wirekit::sidebar.item>
            </x-wirekit::sidebar.group>

            <x-slot:footer>
                <x-wirekit::shell-bar padding="sm" bleed rule="top">
                    <x-wirekit::text size="sm" intent="muted" class="wk-rail-hide">Test mode</x-wirekit::text>
                    <x-slot:end>
                        <x-wirekit::badge intent="warning" size="sm" class="wk-rail-hide">On</x-wirekit::badge>
                    </x-slot:end>
                </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 navigation" />
            </x-slot:start>

            {{-- `schema="false"` because this page already renders a second breadcrumb above:
                 two JSON-LD trails on one document describe two different pages, and a search
                 engine reading both learns nothing from either. --}}
            <x-wirekit::breadcrumb schema="false" :items="[
                ['label' => 'Payments', 'href' => '#'],
                ['label' => 'Transactions'],
            ]" />

            <x-slot:end>
                <x-wirekit::button size="sm" intent="neutral" surface="outline">Export</x-wirekit::button>
                <x-wirekit::button size="sm">Refund</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>

    <x-wirekit::main max="none">
        <x-wirekit::stack gap="md">
            <x-wirekit::stats cols="3">
                <x-wirekit::stat label="Settled" value="$92,400" change="+4.2%" trend="up" />
                <x-wirekit::stat label="In review" value="18" />
                <x-wirekit::stat label="Disputed" value="3" change="+1" trend="down" />
            </x-wirekit::stats>

            <x-wirekit::card>
                <x-wirekit::card.body>
                    <x-wirekit::stack gap="sm">
                        <x-wirekit::heading level="2" size="sm">Latest transaction</x-wirekit::heading>
                        <x-wirekit::text size="sm" intent="muted">Both columns stay put while this area scrolls, and all three bars meet on one rule.</x-wirekit::text>
                    </x-wirekit::stack>
                </x-wirekit::card.body>
            </x-wirekit::card>
        </x-wirekit::stack>

        <x-wirekit::footer max="full">
            <x-wirekit::text size="sm" intent="muted">&copy; 2026 Acme Inc.</x-wirekit::text>
        </x-wirekit::footer>
    </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. `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 class="lg:hidden">
                <x-wirekit::sidebar.toggle 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:

```json
{
  "@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.

### The bars already stay put, and `sticky` will not tell you so

The token above sets the bars' **height**. It has nothing to do with keeping them in view —
that comes out of the shell's structure, and it is worth saying plainly because the usual
instinct is a no-op here.

`viewport` pins the shell's root to the window (`h-dvh overflow-hidden`) and lays the columns
out as a bounded row. Nothing above the content scrolls, because the only scrolling regions in
this layout are `main` and the two navigation columns. **The bars stay where they are because
they never move in the first place.**

So a task that reads "make the sidebar and the top bar sticky" is, in this shell, already done
— and reaching for `position: sticky` produces no error, no warning and no visible change. In a
diff that no-op reads like a decision somebody made, which is what makes it expensive: the next
reader leaves it alone.

The `sticky` prop on `<x-wirekit::shell-bar>` is real and does work — on a page that scrolls as
a document, which this one does not. See
[`shell-bar`'s own note on it](../../components/shell-bar.md) for where it applies.

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

```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">
    Menu
</x-wirekit::button>
```

## Customization

The page's own sections carry the decisions: [Where the account goes](#where-the-account-goes),
[The rule that runs across the columns](#the-rule-that-runs-across-the-columns),
[Full width or boxed](#full-width-or-boxed) and
[Below the breakpoint](#below-the-breakpoint). What is left over is small on purpose:

- **The rail and the module navigation are two components**, and each keeps its own labeling
  mode and its own tone. Matching them is a choice rather than a default — a light module
  column against an inverse rail is the arrangement most consoles land on.
- **The workspace switcher is a slot, not a prop.** Anything that fits the rail's head goes
  there, which is why the nested-navigation example composes rather than configures.

## Production Considerations

- **Three columns are three scroll containers**, and the one that matters is the module
  navigation: it is rendered on every page in that module, so anything expensive inside it is
  expensive everywhere.
- **The workspace switcher is an authorization boundary.** Switching workspace changes what
  every subsequent query may see, and the check belongs on the server for each request — not on
  the switcher, which is a control and can be driven by anyone.
- **Below the breakpoint the rail and the module column travel as one panel.** That is a
  layout decision with a consequence: the drawer is now the only way to reach either, so its
  trigger has to be present on every page, and the background has to be inert while it is open.
- **The horizontal rule across all three columns is a single element by design.** Reproducing
  it as three borders is what produces the seam this shell exists to avoid, and it reappears
  the first time one column's padding changes.

## Accessibility

- **Two `nav` landmarks, named differently** — the module rail and the module's own navigation.
  Same role, same name is what makes a screen reader read them as one.
- **`aria-current="page"`** on the active entry in each, from `:active`.
- **A skip link before the rail**, targeting `main`. With two navigation columns the alternative
  is tabbing through both on every page.
- **The drawer traps focus while it is open and restores it on close**, and the background is
  inert — a keyboard reader must not be able to tab into content the panel is covering.
- **The breadcrumb carries its own structured data** — see
  [The breadcrumb already carries its structured data](#the-breadcrumb-already-carries-its-structured-data) —
  so it is a `nav` landmark with a name of its own rather than a decorated row of links.

## Related

- [App Rail](/components/app-rail) — the module rail, its labeling modes and tones
- [Shell Bar](/components/shell-bar) — the aligned column head
- [App Shell](/components/app-shell) — the layout primitive underneath
