Skip to main content
Exit fullscreen Esc

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.

Console Shell — rail, module navigation, content

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.

Console Shell — a workspace switcher over nested navigation

Pick a card to continue.

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.

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.

Console Shell — every zone filled
Settled
$92,400
increased +4.2%
In review
18
Disputed
3
increased +1

Latest transaction

Both columns stay put while this area scrolls, and all three bars meet on one rule.

© 2026 Acme Inc.

Blade Code

Save this as your application's root layout. Two common locations:

  • resources/views/components/layouts/app.blade.php — the Livewire v4 anonymous-component convention (default in the Livewire Starter Kit). Reference it from a Livewire component class via #[Layout('components.layouts.app')].
  • resources/views/layouts/app.blade.php — the traditional Blade-template path. Reference it from a route or controller via @extends('layouts.app').
{{-- 1. `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:

{
  "@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 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:

{{-- 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, The rule that runs across the columns, Full width or boxed and 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 — so it is a nav landmark with a name of its own rather than a decorated row of links.
  • App Rail — the module rail, its labeling modes and tones
  • Shell Bar — the aligned column head
  • App Shell — the layout primitive underneath