Skip to main content
WireKit
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
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>

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 name is drawn only in the wide rail; in the narrow one it is sr-only, 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.

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.

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.

Theming

Token Role
--size-wk-rail Width in tooltip mode
--size-wk-rail-labeled Width in below mode
--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
--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 false Initial state on a first visit, before storage answers
persist string|null null localStorage key. Null keeps the choice for the session only
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
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
active bool false Marks the current module. A data-current attribute (which Livewire emits on wire:navigate links) is honored too
badge string|int|null null A counter. Digits where a label is visible, a dot where it is not
placement string 'right' Where the tooltip opens. A right-hand rail wants left
scope string|null null Scoped personalization key

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

<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

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".
  • Icons are aria-hidden; the label is the accessible name in every mode.
  • The tooltip's trigger is not made focusable, because the module is already a link — 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 links 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
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