Skip to main content
Copy for LLM

App Rail

<x-wirekit::app-rail> is the narrow, full-height column of application areas that sits outside your ordinary navigation. Each entry is a module — Insights, Billing, Settings — and selecting one decides what the column beside it contains.

It is the second level of navigation that a single sidebar cannot express. A sidebar answers "where in this area am I"; the rail answers "which area am I in".

Usage

Icon-only, with tooltips

label is a prop, not the slot, and it is not optional. In the default mode nothing of it is drawn, so that string is the link's only accessible name — leave it out and a screen reader announces "link", which is what makes most icon rails unusable without sight. Here it is sr-only rather than absent, and it also becomes the tooltip's text.

Labeling

Three ways for a module to name itself.

labels Shape When
tooltip Icon only, name on hover and focus The narrowest column. Default
below A caption under the icon Removes the hover dependency, which matters on touch, where hover does not exist
inline The name beside the icon The rail is the navigation and there is no second column
Captions under the icons
Long names and a counter under the icons

Under the icon a name has the width of its entry and no more. One that does not fit wraps, and a long compound word breaks at a syllable with a hyphen where the page's lang tells the browser how; a word the browser cannot hyphenate still wraps inside the entry. truncate on an item cuts its name with an ellipsis instead.

The icon under which a name sits has its own size, --size-wk-rail-icon-labeled. It is the same as the narrow rail's --size-wk-rail-icon unless you set it, as the example does, so a larger icon in the labeled rail does not widen the narrow one.

Names beside the icons

Expanding to reveal the names

expandable adds a toggle that widens the rail to inline labels and back. It composes with labels rather than replacing it: an expandable labels="below" rail shows captions when narrow and full names when wide.

Pass persist="key" and the choice survives a reload through localStorage.

Expandable, with a persisted choice

The tooltip goes quiet the moment the label becomes visible. That is bound to the live state rather than decided when the page renders — a tooltip repeating a name already on screen gives the link two sources of the same accessible name, which a screen-reader user pays for twice.

Driving it from elsewhere

A trigger outside the rail cannot call its toggle directly: state is merged down the tree, so a button in a top bar is not inside the rail and never sees it. Dispatch a window event instead. The rail also announces its own state on arrival, so an outside trigger can paint the right aria-expanded before its first click rather than guessing.

{{-- 1. Any control anywhere on the page can flip the rail. --}}
<button
    type="button"
    x-data="{ expanded: false }"
    {{-- 2. Listen for the rail's own announcement so this button never lies about the state. --}}
    x-on:wirekit:rail:toggled.window="expanded = $event.detail.expanded"
    :aria-expanded="expanded ? 'true' : 'false'"
    {{-- 3. Fire the toggle. With no id it addresses every rail on the page; pass
            `{ detail: { id: 'main-rail' } }` to address one. --}}
    x-on:click="$dispatch('wirekit:rail:toggle')"
>
    Toggle navigation
</button>

In the shell's drawer on a phone

Below the shell's breakpoint the rail travels off-canvas, inside the shell's drawer. When the drawer holds the rail alone, the rail shows its names there, whether it is expandable or not. A tooltip needs a pointer that can hover, and on a touch screen a tap follows the link before any name could appear. A stored choice is left untouched and applies again at desktop width.

Beside a module column in the shell's sidebar slot, the rail keeps its narrow form in the drawer, because the two columns together already fill a phone.

A labels="below" rail keeps its names there too, and the module column gives way instead: the two sit side by side, and at 88px for the rail and 256px for the menu they would be wider than a 320px screen. Below the breakpoint the menu is at most the screen's width minus the rail's, so the drawer fits a 320px screen and nothing scrolls sideways. From 344px up both keep their width.

The workspace at the top

<x-wirekit::app-rail.brand> is the rail's own head: the mark that identifies the workspace, and — once the rail is wide enough to read them — its name and a quieter second line.

Put it in the brand slot, inside a <x-wirekit::shell-bar> so it lines up with the heads of the columns beside it:

A workspace head that grows with the rail

The bar is about the shell's top rule: it gives the rail's head the same height and the same line as the heads of the columns beside it. Standing the mark on the same spine as the modules is not its job — <x-wirekit::app-rail.brand> carries the rail's own inset itself, so dropping it straight into the slot lines it up with the module glyphs just the same. It simply draws no rule.

The name is drawn only in the wide rail; in the narrow one it is visually hidden, never absent. That matters more here than anywhere else in the component: a narrow rail's head is a circle with two letters in it, and without the name a screen-reader user has no way at all to know whose workspace they are in.

An expandable rail counts as wide the moment it has finished expanding. The mark reads the same live state its modules read, so the name arrives together with theirs — and it waits for the column to stop moving rather than appearing at whatever width the animation is passing through.

The description is dropped entirely when the rail is narrow rather than read out. A subline with no subject beside it — "Free plan", alone — is noise in a landmark summary, and the name above already carries the identity.

For a workspace switcher, wrap it in a dropdown trigger rather than passing href: the control is then a button and announces itself as one.

Two forms of one mark

A rail that opens has two widths, and a brand usually has two forms: a signet that fits the narrow column, and a wordmark that is only legible in the wide one. Put the signet in the default slot and the wordmark in <x-slot:expanded>:

{{-- 1. The rail's brand is the link home and carries the name. --}}
<x-wirekit::app-rail.brand href="/" name="Acme Industries">
    {{-- 2. Each form is a brand with no link of its own, since it already sits in one. --}}
    <x-wirekit::brand :href="false" logo="/brand/signet.svg" dark-logo="/brand/signet-dark.svg" logo-aspect="1/1" size="xs" />

    <x-slot:expanded>
        <x-wirekit::brand :href="false" logo="/brand/wordmark.svg" dark-logo="/brand/wordmark-dark.svg" logo-aspect="4/1" />
    </x-slot:expanded>
</x-wirekit::app-rail.brand>

size sets each form's height and logo-aspect reserves its width before the file arrives; see brand for why the forms must not carry a link of their own.

The two swap with the rail's live state, not with the state it was rendered in — so a rail that starts narrow and is opened by the reader still gets the wordmark. Leave the slot out and nothing changes: the default slot is drawn at every width.

name is what identifies the workspace to anyone not looking at it, and it stays in the accessibility tree at both widths. The two marks are presentation — give them an empty alt and let name do the naming. Labeling the images instead gives you two names for one thing, only one of them present at a time.

The workspace name beside the mark

A rail that expands has room for a name once it is wide, and none at all while it is narrow. Mark anything in the brand slot that is a LABEL, and it appears only in the wide rail:

{{-- 1. The mark is always shown; the name only when the rail is wide enough to hold it. --}}
<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>

The brand row follows the rail's width on its own — centered while the column is an icon strip, and starting on the same vertical line as the modules once it is wide. Passing align to the bar is not required, and an explicit one still wins.

Square app icons

--wk-rail-item-aspect decides the shape of a module in the icon-only rail. It defaults to auto — a pill as tall as its content — and set to 1 every module becomes a square, which is what an app icon is everywhere else.

/* 1. Set it once, in your own app.css, and every icon-only rail follows. */
:root { --wk-rail-item-aspect: 1; }

Which one is right depends on what your rail stands for. A rail of product AREAS usually wants squares, because that is the shape people already read as "an application". A rail that is a list of destinations usually does not.

It applies only where nothing shares the box with the glyph. A square with a word under it is not a square, so labels="below" and labels="inline" ignore it rather than stretching the row.

A pill and a square, side by side

The two rails above differ by that one declaration and nothing else. Hover either column to see what the ratio changes: the hit area, not just the glyph.

Tone

Four surfaces, each re-pointing the same set of --color-wk-rail-* roles. The rail's contents follow automatically: a toned column also re-points the generic neutral tokens for its own subtree, which is how ordinary components inside a dark rail come out legible without any of them knowing that tones exist.

The four tones

A rail with no tone of its own inherits the roles from a toned <x-wirekit::app-shell> above it, so a whole chrome surface can be set in one place — and a variant="flush" sidebar beside it follows, because it paints nothing and the chrome is therefore its surface.

accent is available here only. A shell and a sidebar stop at inverse, because their contents are ordinary components that paint a foreground and a hover surface from two tokens they cannot switch together — and this tone inverts. The rail's items can switch both, through roles built for it. That is also how the reference consoles are built: the colored surface is the rail, the chrome around it is neutral or dark.

accent behaves differently from the other three on purpose: hover and the current module invert rather than tinting — the fill becomes the foreground color and the label becomes the accent. A colored surface has no safe room for a half-measure, and no tint percentage is right for every theme, so the tone borrows the contrast guarantee the accent pair already carries instead of needing one of its own. If you define your own tone, keep the same property: the roles that carry text should be plain aliases, not mixes.

Shape and the current-module marker

variant="panel" makes the rail a floating rounded strip with a gap around it, rather than a column that meets the shell's edges. 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.

A floating panel with an edge marker

The two markers are alternatives rather than a base with an addition — filling the box and drawing the bar reads as two selections.

Grouping

<x-wirekit::app-rail.group> clusters modules. Its heading is drawn only where the rail draws labels at all; in the icon-only column there is no room, and a truncated section title is worse than none. It stays the group's accessible name in every mode regardless, which is what lets a screen-reader user tell two clusters of icons apart.

separated draws a rule above the group. Narrow rails usually cluster with a line rather than a heading, because a heading costs a row of height the column does not have.

Counters

badge renders digits where a label is visible and a dot where it is not. The digits have no room in a 3.5rem column, but an unread signal must not simply vanish where it matters most.

The count stays part of the link's accessible name in both states rather than being hidden — a screen-reader user gets no icon cue at all and would otherwise lose the information outright.

A dot says that something waits, and digits say how many, but neither says what. badge-label takes that sentence. A hover or a focus shows it in a tooltip in every mode: under the name in the icon-only rail, alone where the names are drawn. It is also the link's description, so a screen reader hears the name and then the sentence, and the bare count leaves the name.

A counter that says what it counts

Theming

Token Role
--size-wk-rail Width in tooltip mode
--size-wk-rail-labeled Width in below mode
--size-wk-rail-icon The glyph a module is built around, and the base of --size-wk-rail
--size-wk-rail-icon-labeled The glyph in below mode. Defaults to --size-wk-rail-icon
--size-wk-rail-expanded Width in inline mode, and when expanded
--color-wk-rail-bg Surface
--color-wk-rail-text Foreground
--color-wk-rail-muted Resting module foreground
--color-wk-rail-hover-bg Hover surface
--color-wk-rail-active-bg Current-module fill, in pill mode
--color-wk-rail-active-text Current-module foreground on the column, and the edge marker
--color-wk-rail-hover-fg Foreground on a hover fill — the same color as the column's on a neutral surface, and deliberately not on a colored one
--color-wk-rail-active-fg Foreground on the current module's fill
--color-wk-rail-border Edges and separators
--color-wk-rail-ring Focus ring. Its own role because a ring tuned for the page can fall below the contrast floor on a dark rail
--color-wk-rail-badge The counter dot, and the pill it becomes in a wide rail. Its own role because the rail is the one surface whose background is a tone — on a dark column the page accent is near-black on near-black
--color-wk-rail-badge-fg The digits on that pill
--color-wk-rail-badge-on-fill The counter on a filled item: the current module where the pill marks it, and an item under the pointer. On the accent tone that fill is the counter's own color, so there it inverts with the label
--color-wk-rail-badge-fg-on-fill The digits on that counter in a wide rail
--radius-wk-shell-panel Corner radius in variant="panel"
--radius-wk-nav-item A module's own corner radius. A rounded rail re-points it so the two arcs stay concentric — an inner corner that is not (outer − the gap) makes the space between them pinch at 45°
--wk-rail-item-aspect auto (a pill) or 1 (a square app icon). Icon-only mode only

Override the roles in :root to reskin every tone's fallback, or under a tone's own selector to change one. See Theming.

Props

<x-wirekit::app-rail>

Prop Type Default Description
labels 'tooltip' | 'below' | 'inline' 'tooltip' How a module names itself
expandable bool false Adds a toggle that widens the rail to inline labels and back
expanded bool|null null Initial state on a first visit, before storage answers. null means "no opinion" — the rail waits for the stored preference and seeds nothing. Passing false is DIFFERENT: it seeds a collapsed state into the cookie on the first render, so a reader who has never chosen gets that choice made for them
defaultExpanded bool false The state on a first visit, before the store has anything to say. Unlike expanded it does not turn the seeding off — a stored preference still wins. Both drivers answer it the same way
persist string|null null Storage key. Null keeps the choice for the session only
persistDriver string 'local' Where the choice is remembered: local or cookie. A cookie is the only store the server can read too, so the first render is already right — see below
tone 'default' | 'muted' | 'inverse' | 'accent' 'default' The surface, via the --color-wk-rail-* roles
variant 'flush' | 'panel' 'flush' Edge-to-edge chrome column, or a floating rounded strip
indicator 'pill' | 'edge' 'pill' How the current module is marked
label string 'Modules' Accessible name for the <nav> landmark
scope string|null null Scoped personalization key

<x-wirekit::app-rail.item>

Prop Type Default Description
href string '#' Destination. Written only when the entry is a link
as 'a'|'button' 'a' The element. A module is a destination, so a link is the default; use button for an entry that OPENS something rather than going somewhere — a dropdown trigger, a command palette. See below
icon string|slot|null null An icon name, or markup passed as <x-slot:icon>
label string '' The module's name. Required in practice — it is the link's accessible name in every mode, and the tooltip's text
truncate bool false Cut a visible name that does not fit with an ellipsis instead of wrapping it. Off by default, and meant only for an entry that is not a destination — see below
active bool false Marks the current module. A data-current attribute (which Livewire emits on wire:navigate links) is honored too
current string 'page' What the active module is to a screen reader: page when its link is the page itself, location when the page lies somewhere inside the module, true for neither. The highlight is the same for all three
badge string|int|null null A counter. Digits where a label is visible, a dot where it is not
badge-label string|null null What the counter counts, as a sentence. Shown in a tooltip in every mode and used as the link's description — see Counters. Ignored without a badge
placement string 'right' Where the tooltip opens. A right-hand rail wants left
scope string|null null Scoped personalization key

An entry that opens something

A rail entry is usually a destination, and a link is right for it. An entry that opens a menu is not a destination, and as a link it is subtly broken: a link activates on Enter and not on Space. Measured against this exact pattern — Enter opened the menu and moved focus to the first entry, Escape closed it and returned focus, and Space did nothing, with aria-expanded still false. In a viewport shell Space is not scrolling either, so the key has no effect at all.

There is a second half. Every click on an href="#" pushes a history entry and writes a bare # into the address bar, and a modified click opens a dead duplicate tab.

So give that entry as="button":

<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">Profile</x-wirekit::dropdown.item>
    <x-wirekit::dropdown.item href="/logout">Sign out</x-wirekit::dropdown.item>
</x-wirekit::dropdown>

The entry looks identical: the browser's button chrome is reset by a class in the stylesheet WireKit ships, so this works whether or not your build scans the package. It now activates on both keys, adds nothing to the history, and carries no href for a modified click to open.

Why it is not automatic

Switching to a button whenever href is # would be the tidier API, and it would change the rendered element under every existing caller — including any CSS that names a. This is additive instead: nothing changes until you ask for it.

A long name on that entry

A rail never shortens a module's name by default. "Insig…" does not name a destination — it cannot be told apart from "Insights" or "Insight reports" — so a name that does not fit wraps onto a second line instead.

That argument is about destinations, and the account entry above is not one. It carries a person's name, which stays unambiguous even when it is cut, and a long one wraps that row to roughly twice the height of its neighbors while the rail is expanded. For that entry, opt in:

{{-- 1. truncate cuts the visible name with an ellipsis instead of wrapping it --}}
<x-wirekit::app-rail.item as="button" icon="user" label="Georgiana Morissette" truncate />

The name is cut only on screen. The full string stays the entry's accessible name, and it is repeated as a title, because the tooltip that shows a name in the narrow rail switches itself off while the rail is expanded — without the title, a sighted reader would have no way back to the rest of it.

Only where the name does not tell the entries apart

Do not set truncate on an entry that is a destination. Two modules whose names start the same way become indistinguishable the moment both are cut, and that is the failure the default exists to prevent.

<x-wirekit::app-rail.brand>

Prop Type Default Description
name string|null null The workspace's name. Drawn only in the wide rail; the mark's accessible name in every mode
description string|null null A quieter second line — the plan, the environment, the role. Drawn only beside the name
href string|null null Makes the block a link. Leave it out for a switcher and wrap the component in a dropdown trigger instead, so the control is a button
scope string|null null Scoped personalization key

The default slot is the mark itself — an avatar, a logo. The expanded slot is an optional second form for the wide rail; see Two forms of one mark.

<x-wirekit::app-rail.group>

Prop Type Default Description
label string|null null Section heading. Drawn only where labels are drawn; always the group's accessible name
separated bool false A rule above the group
scope string|null null Scoped personalization key

Slots

Slot Component Purpose
brand app-rail The rail's segment of the shell's top rule — a workspace mark or switcher
default app-rail The module list. Scrolls when it outgrows the column
footer app-rail The bottom cluster — account, help, search. Stays put while the modules scroll
icon app-rail.item Markup for the icon, when a name string is not enough

Remembering the width without a jump

persist stores the reader's choice in localStorage, which no server can read — so the markup carries the collapsed seed. WireKit closes that gap for you: a small script emitted directly after the rail reads the stored value while the page is still parsing and applies the right width before anything is painted. Nothing to configure, and nothing to remember.

It is a correction rather than a requirement, so it can be refused. A Content-Security-Policy that rejects it, or a browser with site storage switched off, puts you back on the old behavior — the rail renders collapsed and widens itself once Alpine boots, which on a page where it was expanded is a visible layout shift.

persist-driver="cookie" needs no script at all: it stores the choice where Blade can read it too, so the first render is already right on the server.

{{-- 1. Same key as before. It also becomes the cookie's name, so there is nothing to keep in sync. --}}
{{-- 2. The browser writes the cookie on toggle; the server reads it on the next render. --}}
<x-wirekit::app-rail expandable persist="app-nav" persist-driver="cookie" />

The first visit, and the ones after it

Use default-expanded for "open the first time, then follow the reader":

<x-wirekit::app-rail expandable persist="app-nav" persist-driver="cookie" default-expanded />

It answers only while the store is silent. A stored preference beats it — which is the point of persisting one — and both drivers answer it the same way: the cookie driver seeds the first render from it, and the local driver renders from it and hands the same value to the store read as its fallback.

expanded and default-expanded are not the same prop

expanded is an override, and with the cookie driver it turns the server-side seeding off entirely: the stored value is not even read. That is deliberate — it is how you pin the state — but it means expanded is the wrong prop for "start open and then remember", and reaching for it there leaves the rail pinned at its value on every visit.

The two drivers have never quite agreed about it either: with local the stored value wins over an explicit expanded, with cookie the explicit one wins. default-expanded is the prop that means the same thing under both.

To pin the state regardless, leave persist unset; without a key the flag helper returns the prop unchanged on every render.

EncryptCookies: add the key to the exception list

The rail writes this cookie from JavaScript, so it is not encrypted. If your app runs Laravel's EncryptCookies middleware (the default), it treats the plain cookie as tampered and drops it on the server read. Add the persist key to the middleware's exception list:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->encryptCookies(except: ['app-nav']);
})

Without it the rail still works — it falls back to reading the raw cookie from the process globals, which is what it always did. That fallback is blind on any server that keeps a worker alive across requests (Octane, FrankenPHP, RoadRunner), because the globals are filled once at boot and never again. The exception is what makes the first render right on those servers.

Why this is opt-in

Writing a cookie is something an application has to be able to account for, so a rail that was asked for localStorage keeps using it. The cookie holds 1 or 0, carries SameSite=Lax, and is read as a boolean, so its value stays out of the page.

Accessibility

  • The rail is a <nav> landmark named "Modules" by default. A console shell has two navigation landmarks side by side, and a screen-reader user moving between landmarks cannot tell them apart unless each says what it is. Override with label, or pass aria-label / aria-labelledby directly — either wins and suppresses the default, so the element never carries two conflicting names.
  • The current module carries aria-current="page", or location when current says the page lies inside the module. Under forced colors, where its fill and its edge bar would not be painted, it is framed in the system's selection color with indicator="pill", and its edge bar is drawn in that color with indicator="edge".
  • Icons are aria-hidden; the label is the accessible name in every mode.
  • The tooltip that shows a module's name does not also describe the link, because its text is the link's name already. A badge-label is the link's description, through an element outside the link, so it is never read as part of the name.
  • The tooltip's trigger is not made focusable, because the module is already focusable in its own right — a link, or a <button> where the entry opens something. The default would put a second tab stop in front of every entry.
  • The expand toggle is a real <button> with aria-expanded and a state-dependent label.
  • The width transition is covered by the library's prefers-reduced-motion handling.

Keyboard Interaction

The rail is a list of destinations inside a navigation landmark, so it uses the platform's own model rather than inventing one — there is no roving tabindex and no arrow-key mode to learn.

Key Action
Tab / Shift+Tab Move through the modules in document order, then into the footer cluster
Enter Follow the focused module, or activate one that carries as="button"
Space Activates an as="button" entry. A link does not respond to it — that is the platform's rule, not ours, and it is the reason the button mode exists
Escape Dismiss the tooltip of the focused module without leaving it

Focus reveals a module's name the same way hover does, so a keyboard user is never left with an unlabeled glyph. On an expandable rail the toggle is an ordinary button in the same tab order, before the modules.

See Also

  • App Shell — the layout that hosts the rail
  • Shell Bar — the aligned column head used in the brand slot
  • Sidebar — the module's own navigation, beside the rail

Updated in WireKit v2.64.0 (2026-10-04)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store