Skip to main content
WireKit

Sidebar Shell

One navigation column running the full height of the window, the content beside it, and the top bar inside that content column rather than spanning the whole width. It is the shape most admin panels and dashboards take, and the reason is the column: everything the application can do is visible at once, in one list, without a second level of chrome.

Leave it the moment the application has several product areas. A single column then has to answer two questions at the same time — which area am I in, and where am I inside it — and it answers both badly. That is where the console shape starts, with a rail for the areas and a column for the pages inside one.

Composed from: app-shell, sidebar, shell-bar, main.

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.

Sidebar Shell — one navigation column, the bar inside the content

Dashboard

Revenue
$48,120
increased +12.4%
Open invoices
37
decreased -4.1%
Customers
1,284
increased +3.0%
Churn
1.8%
increased -0.3%

Figures update hourly. The column on the left stays put while this area scrolls.

Collapsing the column to icons

A column that is always open costs the same width on every page, and on a laptop that width is real estate the content wanted. collapsible gives the reader a handle to give it back: the labels go, the icons stay, and the column narrows to the width of one icon plus its padding.

The labels are not removed from the page when this happens — they become sr-only, so a screen reader still hears "Customers" where a sighted reader now sees only the icon. That is the whole reason the item takes a label at all rather than an icon with a tooltip.

persist remembers the reader's choice across page loads by writing it to the browser's own storage, which is what you want in a real application. It is deliberately absent from the preview below: a preview whose rendered state depends on the visitor's storage shows different things to different people, and one of them is always the wrong one to document.

The same column, collapsed to icons

Dashboard

The column keeps its icons and gives the labels back to the content.

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'), or from a Livewire component class via #[Layout('layouts.app')].

Pick whichever matches the scaffolding your fresh Laravel install or starter kit already ships with.

{{-- 1. The skip link sits OUTSIDE the shell and before it. Under
     header-placement="content" the navigation column comes first in DOM order, so a skip
     link placed inside would be reached only after every navigation item — which is the
     one thing it exists to prevent. --}}
<x-wirekit::skip-link />

{{-- 2. `viewport` makes the shell fill the window rather than flow with the page. It is
     what turns this from a layout into a shell. --}}
<x-wirekit::app-shell viewport header-placement="content" :sidebar-inset="false">

    {{-- 3. The navigation column. `variant="flush"` draws no surface of its own, so the
         column sits directly on the shell's background instead of being a panel inside it. --}}
    <x-slot:sidebar>
        <x-wirekit::sidebar variant="flush" collapsible persist="app.sidebar" label="Main navigation">

            {{-- 4. The column's own head. `bleed` lets the bar reach the column's edges, so
                 its bottom edge lines up with the bar in the content column beside it. --}}
            <x-slot:header>
                <x-wirekit::shell-bar padding="sm" bleed>
                    <x-wirekit::avatar initials="AC" size="sm" />
                    <x-wirekit::text weight="semibold">Acme Console</x-wirekit::text>
                </x-wirekit::shell-bar>
            </x-slot:header>

            {{-- 5. `icon` takes a VOCABULARY name, not a glyph name — so the same markup
                 keeps its meaning if the application changes icon set. --}}
            <x-wirekit::sidebar.group label="Overview">
                <x-wirekit::sidebar.item href="{{ route('dashboard') }}" icon="dashboard" :active="request()->routeIs('dashboard')">Dashboard</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="{{ route('customers.index') }}" icon="users" :active="request()->routeIs('customers.*')">Customers</x-wirekit::sidebar.item>
                <x-wirekit::sidebar.item href="{{ route('invoices.index') }}" icon="file-text" :active="request()->routeIs('invoices.*')">Invoices</x-wirekit::sidebar.item>
            </x-wirekit::sidebar.group>

            <x-wirekit::sidebar.group label="Workspace">
                <x-wirekit::sidebar.item href="{{ route('settings') }}" icon="settings" :active="request()->routeIs('settings')">Settings</x-wirekit::sidebar.item>
            </x-wirekit::sidebar.group>

            {{-- 6. The account menu goes in the column's footer zone. Nothing needs to be
                 pushed down by hand — the zone is already anchored to the bottom. --}}
            <x-slot:footer>
                <x-wirekit::dropdown placement="top-start">
                    <x-slot:trigger>
                        <x-wirekit::profile :name="auth()->user()->name" :avatar="['initials' => auth()->user()->initials()]" interactive />
                    </x-slot:trigger>
                    <x-wirekit::dropdown.item href="{{ route('profile') }}">Profile</x-wirekit::dropdown.item>
                    <x-wirekit::dropdown.item href="{{ route('preferences') }}">Preferences</x-wirekit::dropdown.item>
                    <x-wirekit::dropdown.separator />
                    <x-wirekit::dropdown.item href="{{ route('logout') }}" danger>Sign out</x-wirekit::dropdown.item>
                </x-wirekit::dropdown>
            </x-slot:footer>
        </x-wirekit::sidebar>
    </x-slot:sidebar>

    {{-- 7. The toggle belongs in the bar's `start` slot, which is pinned. The default slot
         scrolls when the bar overflows, and on a phone it overflows immediately — so a
         toggle placed there scrolls out of reach exactly when it is needed. --}}
    <x-slot:header>
        <x-wirekit::shell-bar>
            <x-slot:start>
                <x-wirekit::sidebar.toggle class="lg:hidden" aria-label="Open navigation" />
            </x-slot:start>

            <x-wirekit::heading level="1" size="md">{{ $title ?? 'Dashboard' }}</x-wirekit::heading>

            <x-slot:end>
                <x-wirekit::button intent="neutral" surface="ghost" size="sm" href="{{ route('exports') }}">Export</x-wirekit::button>
                <x-wirekit::button size="sm" href="{{ route('invoices.create') }}">New invoice</x-wirekit::button>
            </x-slot:end>
        </x-wirekit::shell-bar>
    </x-slot:header>

    {{-- 8. `id="main-content"` is what the skip link in step 1 jumps to, and setting it also
         gives the landmark tabindex="-1" so the jump moves FOCUS and not just the scroll
         position. `max="none"` lets the content use the full column width. --}}
    <x-wirekit::main max="none" id="main-content">
        {{ $slot }}
    </x-wirekit::main>
</x-wirekit::app-shell>

Customization

What How
Column width Set --wk-sidebar-w on the shell or any ancestor — the sidebar reads it and falls back to 16rem. It is a CSS variable rather than a prop so a whole application can set it once.
Collapsed width Fixed at the width of one icon plus its padding. It is not themeable on purpose: the collapsed column exists to be exactly as wide as its icons, and a different value makes it a narrow column instead of an icon rail.
Column surface variant="flush" above draws no surface of its own, which is what a column against the shell's own background wants. Drop it for a panelled column with its own border and elevation.
Bar height and padding padding on shell-bar (sm, default, lg). The bar's bottom edge is the shell's horizontal rule, so it aligns with the same edge in the other columns automatically.
Navigation items sidebar.group for the headings, sidebar.item for the rows. The icon prop takes a vocabulary name, not a glyph name, so it survives a change of icon set.
Account menu The footer slot of the sidebar. Anything can go there; the composition above uses a dropdown because a menu of account actions is what most applications put there.

Accessibility

Everything below is what the components emit — there is nothing to wire up by hand.

  • Skip link<x-wirekit::skip-link /> at the top of the page, visible on keyboard focus, targeting the main landmark. It is the first thing a keyboard reader meets, and it exists because the alternative is tabbing through the whole navigation column on every page.
  • Landmarks — the sidebar renders <nav> with the label you gave it as its accessible name; main renders <main>; shell-bar renders a <header> inside the content column. Three landmarks, each named, which is what lets a screen reader jump between them.
  • aria-current="page" on the item marked active — the item is a link to a page, and this says which page you are on. It is emitted for you; do not add it by hand.
  • The collapse toggle carries an aria-label that reflects the state it will move to, and the item labels become sr-only rather than being removed, so the column keeps its full meaning for a screen reader when it is visually reduced to icons.
  • The mobile toggle carries aria-label="Open navigation" and sits in the bar's start slot, which is pinned. In the default slot it would scroll out of reach on a phone.
  • App Shell — the primitive this page composes
  • Sidebar — the navigation column itself
  • Shell Bar — the top bar inside the content column