---
title: Rail Shell
description: A single module rail against an inset content panel — the console shape for an application whose areas need no second navigation column.
visibility: guest
draft: false
category: application-shells
tags: [application-shells, shell, rail, panel, tone]
dependencies: [app-rail, app-shell, avatar, badge, button, card, dropdown, footer, heading, main, row, shell-bar, sidebar, stack, stat, stats, text]
responsive: true
dark_compatible: true
---

# Rail Shell

The console shape without a second navigation column: a module rail on the outside, and the
content directly beside it as an inset panel. Use it when each area is one screen — a board, a
queue, an editor — and a second list of destinations would be a list with one entry.

It is also where the shell's two color decisions become visible. The chrome carries a tone, the
content panel does not, and the radius between them is what makes the panel read as a sheet
lying on the chrome rather than as a fourth border.

**Composed from:** `app-rail`, `app-shell`, `avatar`, `badge`, `button`, `card`, `dropdown`, `footer`, `heading`, `main`, `row`, `shell-bar`, `sidebar`, `stack`, `stat`, `stats`, `text`.

> **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="Rail Shell — dark chrome, inset content panel" wide bleed height="28rem"}
<x-wirekit::app-shell viewport panel tone="inverse" 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="AI" size="sm" />
                </x-wirekit::shell-bar>
            </x-slot:brand>
            <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" />
            <x-wirekit::app-rail.item href="#" icon="file-text" label="Invoicing" :active="true" />
            <x-wirekit::app-rail.item href="#" icon="inbox" label="Inbox" badge="5" />
            <x-wirekit::app-rail.item href="#" icon="users" label="Suppliers" />
            <x-wirekit::app-rail.item href="#" icon="tag" label="Labels" />
            <x-slot:footer>
                <x-wirekit::app-rail.item href="#" icon="bell" label="Alerts" />
                <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
                <x-wirekit::app-rail.item href="#" icon="user" label="Account" />
            </x-slot:footer>
        </x-wirekit::app-rail>
    </x-slot:rail>
    <x-slot:header>
        <x-wirekit::shell-bar>
            <x-slot:start class="lg:hidden">
                {{-- Below `lg` the rail travels off-canvas, so the bar owes it a handle.
                     `start` keeps the handle pinned while the cluster beside it scrolls —
                     put it in the default slot and it scrolls away with the title. --}}
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::heading level="1" size="md">AI Invoicing</x-wirekit::heading>
            <x-slot:end>
                <x-wirekit::button size="sm" intent="neutral" surface="outline">Filters</x-wirekit::button>
                <x-wirekit::button size="sm">Upload invoice</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::text size="sm" intent="muted">128 documents, 24 awaiting review.</x-wirekit::text>
            <x-wirekit::card>
                <x-wirekit::card.body>
                    <x-wirekit::stack gap="sm">
                        <x-wirekit::row gap="sm">
                            <x-wirekit::badge intent="warning">Manual review</x-wirekit::badge>
                            <x-wirekit::text size="sm" intent="muted">Dec 30, 2025</x-wirekit::text>
                        </x-wirekit::row>
                        <x-wirekit::text weight="medium">Ford Civil Contracting Pty Ltd.</x-wirekit::text>
                        <x-wirekit::text size="sm" intent="muted">PO-1048 · Job-001</x-wirekit::text>
                    </x-wirekit::stack>
                </x-wirekit::card.body>
            </x-wirekit::card>
        </x-wirekit::stack>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

## Blade Code

```blade
{{-- 1. `tone="inverse"` puts the shell's chrome on the dark surface; `panel` lifts the
        content column off it as an inset, rounded sheet. --}}
{{-- 2. The rail inherits the shell's tone — it needs no `tone` of its own, and giving it
        one is how you make it differ from the chrome deliberately. --}}
<x-wirekit::app-shell viewport panel tone="inverse" header-placement="content">
    <x-slot:rail>
        {{-- 3. `indicator="edge"` marks the current module with a bar on the rail's
                inline-end edge instead of filling its box — which reads better in a very
                narrow column, where a filled box dominates everything beside it. --}}
        <x-wirekit::app-rail indicator="edge">
            <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>

            <x-wirekit::app-rail.item href="{{ route('invoicing') }}" icon="file-text" label="Invoicing" />
            <x-wirekit::app-rail.item href="{{ route('inbox') }}" icon="inbox" label="Inbox" />

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

    {{-- 4. With no `sidebar` slot the content column begins directly after the rail. --}}
    <x-slot:header>
        <x-wirekit::shell-bar>
            {{-- 5. The handle for the drawer the rail becomes below `lg`. Without it the
                    rail is off-canvas on a phone with nothing to open it. --}}
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::heading level="1" size="md">AI Invoicing</x-wirekit::heading>
            <x-slot:end>
                <x-wirekit::button size="sm">Upload invoice</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>

    <x-wirekit::main max="none">
        {{ $slot }}
    </x-wirekit::main>
</x-wirekit::app-shell>
```

## Letting the rail show its names

An application with one navigation level often wants the rail wide enough to read. Add
`expandable` and the reader chooses; `persist` remembers the choice.

:::preview{title="Expandable, with the names visible" wide bleed height="24rem"}
<x-wirekit::app-shell viewport panel tone="muted" header-placement="content">
    <x-slot:rail>
        <x-wirekit::app-rail expandable expanded persist="wk-docs-rail-shell">
            <x-slot:brand>
                <x-wirekit::shell-bar padding="none">
                    <x-wirekit::avatar initials="AD" size="sm" />
                    <x-wirekit::text size="sm" weight="medium" data-wk-rail-brand-label>Acme Design</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:brand>
            <x-wirekit::app-rail.item href="#" icon="home" label="Home" :active="true" />
            <x-wirekit::app-rail.item href="#" icon="folders" label="Ads library" />
            <x-wirekit::app-rail.item href="#" icon="file-text" label="Briefs" />
            <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Reports" badge="4" />
            <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:header>
        <x-wirekit::shell-bar>
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>
            <x-wirekit::heading level="1" size="md">Home</x-wirekit::heading>
        </x-wirekit::shell-bar>
    </x-slot:header>
    <x-wirekit::main max="none">
        <x-wirekit::text size="sm" intent="muted">Collapse the rail with the control above the module list. The choice survives a reload.</x-wirekit::text>
    </x-wirekit::main>
</x-wirekit::app-shell>
:::

## Everything at once

The previews above each hold one idea still so it can be read. 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 studying a decision. The rail carries a brand mark, two
labeled groups, an unread count, an expand control and a footer cluster with an account menu
behind it; the panel carries a bar with both clusters and a page footer under the content.

::: 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="Rail Shell — every zone filled" wide bleed height="34rem"}
<x-wirekit::app-shell viewport panel tone="inverse" header-placement="content">
    <x-slot:rail>
        <x-wirekit::app-rail indicator="edge" expandable persist="wk-docs-rail-shell-full">
            <x-slot:brand>
                <x-wirekit::shell-bar padding="none" align="center">
                    <x-wirekit::avatar initials="AI" size="sm" />
                    <x-wirekit::text weight="semibold" class="wk-rail-hide">Acme AI</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:brand>

            <x-wirekit::app-rail.group label="Work">
                <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" />
                <x-wirekit::app-rail.item href="#" icon="file-text" label="Invoicing" :active="true" />
                <x-wirekit::app-rail.item href="#" icon="inbox" label="Inbox" badge="5" />
            </x-wirekit::app-rail.group>

            <x-wirekit::app-rail.group label="Records">
                <x-wirekit::app-rail.item href="#" icon="users" label="Suppliers" />
                <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="bell" label="Alerts" badge="2" />
                <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
                <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:header>
        <x-wirekit::shell-bar>
            <x-slot:start class="lg:hidden">
                <x-wirekit::sidebar.toggle aria-label="Open navigation" />
            </x-slot:start>

            <x-wirekit::heading level="1" size="md">AI Invoicing</x-wirekit::heading>

            <x-slot:end>
                <x-wirekit::button size="sm" intent="neutral" surface="outline">Filters</x-wirekit::button>
                <x-wirekit::button size="sm">Upload invoice</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="Documents" value="128" />
                <x-wirekit::stat label="Awaiting review" value="24" change="-6" trend="down" />
                <x-wirekit::stat label="Settled today" value="41" change="+9" trend="up" />
            </x-wirekit::stats>

            <x-wirekit::card>
                <x-wirekit::card.body>
                    <x-wirekit::stack gap="sm">
                        <x-wirekit::row gap="sm">
                            <x-wirekit::badge intent="warning">Manual review</x-wirekit::badge>
                            <x-wirekit::text size="sm" intent="muted">Dec 30, 2025</x-wirekit::text>
                        </x-wirekit::row>
                        <x-wirekit::text weight="medium">Ford Civil Contracting Pty Ltd.</x-wirekit::text>
                        <x-wirekit::text size="sm" intent="muted">PO-1048 · Job-001</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>
:::

## Choosing the two colors

The chrome and the content are separate decisions, and both are token-level.

| Want | Set |
| --- | --- |
| Dark chrome, light content | `tone="inverse"` on the shell, plus `panel` |
| A quiet step down from the page | `tone="muted"` |
| Brand-colored chrome | `tone="accent"` |
| A rail that differs from the rest of the chrome | a `tone` on the rail as well as the shell |
| A different radius for the sheet | `--radius-wk-shell-panel` |
| Different colors entirely | the `--color-wk-rail-*` roles |

The panel's radius is its own token rather than the generic large radius, because the sheet is
the biggest rounded surface on the page: a theme that wants sharp cards with a soft shell — or
the reverse — has no way to say so if the two share one value.

**The inset is even on all four sides**, and that is what makes it read as a sheet. It used to
round only the corners facing the chrome and sit flush against it — which opens a wedge of
chrome beside the rail's straight edge and reads, correctly, as a gap rather than as a panel. A
curve needs something to curve away from. It uses the same token the rail's own `panel` variant
insets by, so a floating rail and a floating content panel sit on one grid.

A navigation column standing beside an inset panel **draws no edge of its own**: the gap is the
separation, and its border would be a line floating in the middle of the chrome.

Below the breakpoint the inset is dropped, and the edges come back with it. The navigation is a
drawer there and the content is the whole viewport, so an inset would spend horizontal room a
phone does not have on a decoration nothing is beside.

### Would a real browser round the outer corners?

No, and it does not. Every rounded corner in this family faces INWARD — the content panel's
corners face the chrome it lies on. The shell's own outer edge carries no radius at all, so in a
real full-viewport application it meets the browser's sharp edge squarely, which is the only
thing that looks right there.

## Customization

The page's own sections carry the two decisions this shell actually asks of you:
[Letting the rail show its names](#letting-the-rail-show-its-names) for the labeling mode and
whether the reader's choice is remembered, and
[Choosing the two colors](#choosing-the-two-colors) for the chrome-and-content pair, which is
token-level on both sides.

Beyond those, the shell is deliberately short of knobs:

- **The rail's width follows its labeling mode.** It is not a prop, because a rail wide enough
  for names and a rail sized for icons are two different components' worth of spacing, and the
  mode already says which one you meant.
- **The inset panel is the shell's own surface**, so it takes its radius and its lift from the
  panel tokens rather than from a class on this page — override the token and every shell in
  the application follows.

## Production Considerations

- **`persist` writes the reader's expand choice to their browser**, which makes it a preference
  that survives a deploy and does not survive a new device. If it should follow the account,
  store it server-side and pass the value in; the prop is the local convenience, not the
  contract.
- **The rail renders before the route resolves**, so the active item has to come from the
  server. Deciding it in the browser produces a first paint with nothing marked, and the reader
  sees the highlight arrive late on every navigation.
- **A rail with more modules than fit is a scroll, not an overflow menu.** Modules hidden behind
  a "more" affordance are the ones nobody finds; if the list is genuinely long, this is the
  wrong shell and the console shape with a second column is the right one.
- **The icons come from an icon preset**, which is a peer dependency. A production build without
  it renders a rail of empty squares — the doctor command checks for exactly this.

## Accessibility

- **One `nav` landmark**, named by the rail's `label`. If the page has a second navigation
  region, the two names have to differ or a screen reader lists them identically.
- **`aria-current="page"` on the active module**, emitted from `:active`. The visual highlight
  says nothing to a non-visual reader, and this is the only thing that does.
- **Every module is a real link.** Tab reaches it, Enter follows it, and the browser's own
  affordances — open in a new tab, copy the address — work without anything being wired up.
- **The expand toggle is a button with an accessible name and `aria-expanded`**, so the state is
  announced rather than implied by the width.
- **Icon-only mode still names every item.** The label is present and visually hidden, which is
  what keeps the rail readable when the names are not on screen.

## Related

- [Console Shell](/blueprints/application-shells/console-shell) — the same rail with a second navigation column
- [App Rail](/components/app-rail) — labeling modes, tones and the expand toggle
- [Theming](/theming) — the token roles behind the tones
