Skip to main content
Exit fullscreen Esc

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.

Rail Shell — dark chrome, inset content panel

AI Invoicing

128 documents, 24 awaiting review.

Manual review

Dec 30, 2025

Ford Civil Contracting Pty Ltd.

PO-1048 · Job-001

Blade Code

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

Expandable, with the names visible

Home

Collapse the rail with the control above the module list. The choice survives a reload.

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.

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.

Rail Shell — every zone filled

AI Invoicing

Documents
128
Awaiting review
24
decreased -6
Settled today
41
increased +9
Manual review

Dec 30, 2025

Ford Civil Contracting Pty Ltd.

PO-1048 · Job-001

© 2026 Acme Inc.

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 for the labeling mode and whether the reader's choice is remembered, and 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.
  • Console Shell — the same rail with a second navigation column
  • App Rail — labeling modes, tones and the expand toggle
  • Theming — the token roles behind the tones