App Rail
<x-wirekit:: 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
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 |
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.
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.
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:: 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:: so it lines up with the heads of
the columns beside it:
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:: 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.
auto — a pill
1 — an app icon
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.
A rail with no tone of its own inherits the roles from a toned <x-wirekit:: 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.
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:: 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.
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.
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.
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 propexpanded 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.
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.
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 withlabel, or passaria-label/aria-labelledbydirectly — either wins and suppresses the default, so the element never carries two conflicting names. - The current module carries
aria-current="page", orlocationwhencurrentsays 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 withindicator="pill", and its edge bar is drawn in that color withindicator="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-labelis 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>witharia-expandedand a state-dependent label. - The width transition is covered by the library's
prefers-reduced-motionhandling.
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.