---
title: App Rail
description: Full-height module rail with tooltip, caption, or inline labels
visibility: guest
draft: false
---

# 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

:::preview{title="Icon-only, with tooltips"}
<x-wirekit::app-rail style="height: 18rem;">
    <x-slot:brand>
        <x-wirekit::shell-bar padding="none" align="center">
            <x-wirekit::avatar initials="SW" size="sm" />
        </x-wirekit::shell-bar>
    </x-slot:brand>
    <x-wirekit::app-rail.item href="#" icon="home" label="Overview" :active="true" />
    <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" />
    <x-wirekit::app-rail.item href="#" icon="credit-card" label="Payments" badge="3" />
    <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    <x-slot:footer>
        <x-wirekit::app-rail.item href="#" icon="user" label="Account" />
    </x-slot:footer>
</x-wirekit::app-rail>
:::

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

:::preview{title="Captions under the icons"}
<x-wirekit::app-rail labels="below" style="height: 16rem;">
    <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" :active="true" />
    <x-wirekit::app-rail.item href="#" icon="database" label="Schema" />
    <x-wirekit::app-rail.item href="#" icon="shield" label="Security" />
    <x-wirekit::app-rail.item href="#" icon="settings" label="Config" />
</x-wirekit::app-rail>
:::

:::preview{title="Names beside the icons"}
<x-wirekit::app-rail labels="inline" style="height: 16rem;">
    <x-wirekit::app-rail.group label="Workspace">
        <x-wirekit::app-rail.item href="#" icon="home" label="Home" :active="true" />
        <x-wirekit::app-rail.item href="#" icon="folders" label="Projects" />
        <x-wirekit::app-rail.item href="#" icon="users" label="Team" badge="12" />
    </x-wirekit::app-rail.group>
    <x-wirekit::app-rail.group label="Account" :separated="true">
        <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    </x-wirekit::app-rail.group>
</x-wirekit::app-rail>
:::

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

:::preview{title="Expandable, with a persisted choice"}
<x-wirekit::app-rail expandable persist="wk-docs-rail" style="height: 18rem;">
    <x-slot:brand>
        <x-wirekit::shell-bar padding="none" align="center">
            <x-wirekit::avatar initials="AC" size="sm" />
        </x-wirekit::shell-bar>
    </x-slot:brand>
    <x-wirekit::app-rail.item href="#" icon="home" label="Home" :active="true" />
    <x-wirekit::app-rail.item href="#" icon="inbox" label="Inbox" badge="8" />
    <x-wirekit::app-rail.item href="#" icon="calendar" label="Calendar" />
    <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
</x-wirekit::app-rail>
:::

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.

```blade
{{-- 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:

:::preview{title="A workspace head that grows with the rail"}
<x-wirekit::app-rail labels="inline" style="height: 14rem;">
    <x-slot:brand>
        <x-wirekit::shell-bar padding="none" align="start">
            <x-wirekit::app-rail.brand name="Acme Inc." description="Free plan">
                <x-wirekit::avatar initials="AC" size="sm" />
            </x-wirekit::app-rail.brand>
        </x-wirekit::shell-bar>
    </x-slot:brand>
    <x-wirekit::app-rail.item href="#" icon="home" label="Home" :active="true" />
    <x-wirekit::app-rail.item href="#" icon="folders" label="Projects" />
</x-wirekit::app-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:

```blade
{{-- 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.

```css
/* 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.

:::preview{title="A pill and a square, side by side"}
<x-wirekit::row gap="lg">
    <x-wirekit::stack gap="sm" style="align-items: center;">
        <x-wirekit::app-rail style="height: 11rem;">
            <x-wirekit::app-rail.item href="#" icon="home" label="Overview" :active="true" />
            <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" />
            <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
        </x-wirekit::app-rail>
        <x-wirekit::text size="sm" variant="muted">auto — a pill</x-wirekit::text>
    </x-wirekit::stack>
    <x-wirekit::stack gap="sm" style="align-items: center;">
        <x-wirekit::app-rail style="height: 11rem; --wk-rail-item-aspect: 1;">
            <x-wirekit::app-rail.item href="#" icon="home" label="Overview" :active="true" />
            <x-wirekit::app-rail.item href="#" icon="chart-bar" label="Insights" />
            <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
        </x-wirekit::app-rail>
        <x-wirekit::text size="sm" variant="muted">1 — an app icon</x-wirekit::text>
    </x-wirekit::stack>
</x-wirekit::row>
:::

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.

:::preview{title="The four tones"}
<x-wirekit::row gap="md" style="flex-wrap: wrap;">
    <x-wirekit::app-rail tone="default" style="height: 12rem;">
        <x-wirekit::app-rail.item href="#" icon="home" label="Default" :active="true" />
        <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    </x-wirekit::app-rail>
    <x-wirekit::app-rail tone="muted" style="height: 12rem;">
        <x-wirekit::app-rail.item href="#" icon="home" label="Muted" :active="true" />
        <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    </x-wirekit::app-rail>
    <x-wirekit::app-rail tone="inverse" style="height: 12rem;">
        <x-wirekit::app-rail.item href="#" icon="home" label="Inverse" :active="true" />
        <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    </x-wirekit::app-rail>
    <x-wirekit::app-rail tone="accent" style="height: 12rem;">
        <x-wirekit::app-rail.item href="#" icon="home" label="Accent" :active="true" />
        <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
    </x-wirekit::app-rail>
</x-wirekit::row>
:::

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.

:::preview{title="A floating panel with an edge marker"}
<x-wirekit::app-rail tone="inverse" variant="panel" indicator="edge" style="height: 16rem;">
    <x-wirekit::app-rail.item href="#" icon="home" label="Home" :active="true" />
    <x-wirekit::app-rail.item href="#" icon="folders" label="Files" />
    <x-wirekit::app-rail.item href="#" icon="settings" label="Settings" />
</x-wirekit::app-rail>
:::

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](/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](/components/app-shell) — the layout that hosts the rail
- [Shell Bar](/components/shell-bar) — the aligned column head used in the `brand` slot
- [Sidebar](/components/sidebar) — the module's own navigation, beside the rail
