Sidebar
The <x-wirekit::sidebar> component is a vertical navigation landmark with grouped items. It composes from three sub-components: the sidebar container, groups with optional labels, and individual items.
Standalone vs. app-shell-hosted sidebar. The previews on THIS page show the sidebar rendered standalone, which is useful when you want full control over its placement (a marketing-page resource navigator, a settings drawer inside a card, a custom dashboard layout). When you want a sidebar wired into the canonical dashboard chrome — sticky header, push-not-overlay body, mobile-collapse handled automatically — drop it into
<x-wirekit::app-shell>instead. See App Shell for the integrated layout walk-through.
Usage
Without Groups
If you don't need logical sections, drop items directly into the sidebar:
With Icons
Collapse-to-Icon Rail
Add collapsible to let the sidebar collapse to a narrow icon-only rail. An auto-rendered toggle flips the state; in the collapsed rail the item labels become sr-only (so links keep their accessible names for screen readers), the icons stay visible — joined by an unread counter where an item carries one, degraded to a dot — and the group / submenu chrome hides. Pass persist="key" to remember the collapsed state across reloads via localStorage.
Collapsible Sub-Groups
Use sidebar.collapsible for expandable sections with child items — ideal for nested navigation structures:
Showing that the list continues
A long navigation column scrolls, and a scrollbar alone is easy to miss — especially on a track that fades out when idle. The scroll shadows say the same thing in the reader's peripheral vision: a shadow means there is more content that way, and its absence means you have reached that end.
Scroll the list below. At the top there is a shadow under the fold and none above it; at the
bottom the reverse. It is driven by two one-pixel sentinels and an IntersectionObserver, so
the shadow appears exactly when there is somewhere to scroll to — not on a timer, and not
statically like a mask, which would dim the last item even once you have reached it.
The same affordance exists on the inline axis — wk-scroll-shadow-start / -end — for a bar
that scrolls sideways. See the public CSS API.
Keeping a counter visible when the group is closed
A group is collapsed to keep the list short — and the counters on the items inside go with it.
With persist, that is permanent: collapse once and the numbers are never seen again without
going to look. The trailing slot on the trigger is where you put whatever should survive.
Alpine's open is in scope, so you decide when it shows. x-show="! open" is the usual
choice: once the group is expanded, the individual counters are visible and a second summary of
the same quantity is one too many.
A slot rather than a badge prop, deliberately. A count is one answer; a silent dot is another,
and sometimes the better one — a total across several queues asserts an urgency the number
cannot actually know. The slot takes either, or a warning icon, or nothing.
Here is that second answer, built. Expanded, nothing changes — the per-item counters carry the
detail, because when you can see 2 and 1 you do not need a summary of them. Collapsed, the
group says only something is waiting:
The dot carries role="img" and an aria-label, and that is not decoration of the markup — it
is the whole difference between the two variants for a reader who does not see it. 3 announces
itself; a colored circle announces nothing at all, so without a name the collapsed group is
silent in exactly the way the visible design is trying not to be. Color alone is also not a
signal that reaches everyone, which is the second reason the name is not optional.
It is written as elements rather than a component because WireKit has no bare-dot primitive
today: <x-wirekit::badge :dot="true"> puts a dot inside a pill, which is the shape this variant
exists to avoid.
The nesting is not decoration either, and it is the part worth copying carefully. x-show works
by writing to the element's own display, so it OVERWRITES a display you set in the same style
attribute — and an empty element that falls back to display: inline has no box at all, however
much width and height you give it. The first version of this demo was exactly that, and it
rendered nothing: computed size 8px, painted size zero. So the outer element carries the toggle
and the accessible name, and the inner one carries the shape, where nothing will take it away.
The count demo above never meets this because a badge has text, and text gives an inline element
a box on its own.
Set :open="true" to expand a group by default. The chevron rotates to indicate the expanded state.
Submenu Indicator
Add :submenu="true" to any sidebar item to show a chevron that signals a flyout or sub-navigation exists. This is purely visual — it does not add any behavior. Useful when an item opens a separate panel, drawer, or flyout menu:
Unread Counters
Set badge on any item to render a trailing counter. It sits outside the label's
truncating span, so a long label can never clip it:
In a collapsible sidebar the counter follows the rail: expanded it reads as a pill after the label, and once collapsed it degrades to a dot on the icon — the count itself has no room at rail width, but the unseen-items signal survives, which is the part that matters when the labels are gone.
The counter stays part of the item's accessible name in both states, so the link
above announces as "Inbox 12". Nothing is hidden from assistive technology when
the rail collapses, even though the digits stop being drawn. Note that this is a
deliberate choice rather than the only option: hiding the counter from the name
and announcing it separately is also defensible, and if you have already built
that pattern by hand, adopting badge will change what your users hear.
Collapsible Groups
Give <x-wirekit::sidebar.group> the collapsible prop to fold a whole section under its heading — the uppercase label becomes a disclosure button with a chevron, and the items collapse below it. Pass persist="key" and the open/closed state is remembered across reloads in localStorage. Use one key per group: a per-group key is more robust than a single shared map, which is typically keyed by the label and then loses all state on a language switch (and folds two same-labeled groups together).
This is the section-heading counterpart to <x-wirekit::sidebar.collapsible> (which looks like a nav item with an icon). sidebar.collapsible also accepts persist now, so either affordance can remember its state:
Collapsible sections in the icon rail
When a collapsible sidebar folds to its icon rail, a <x-wirekit::sidebar.collapsible> flattens instead of disappearing: its section heading hides (unreadable at rail width) and its child items stay reachable as centered icons — exactly like a static group. Collapse the rail below and the Projects items remain as icons.
Section-heading trigger
Set variant="heading" to render the trigger as a small uppercase section label instead of a nav-item row — the section-heading look, but still an icon-carrying disclosure you can fold:
Flush Column
variant="flush" turns the sidebar into a full-bleed navigation column: no radius, no
border around it, the surface inherited from whatever hosts it, and a single edge on
the inline-end side separating it from the content.
Two variants rather than a set of switches, because the halves are not independent — a rounded panel with no border reads as a rendering fault, and a full-bleed column with a radius shows a sliver of the page at each corner:
Inside <x-wirekit::app-shell> pair it with :sidebar-inset="false" — see
Flush Column Layout for the whole shell.
Fixed Head and Foot
A real navigation column is almost always three parts: a fixed head carrying the
brand, a scrolling list of groups, and a fixed foot carrying the account menu. Fill
the header and footer slots and the sidebar builds that structure, including the
scroll mechanics:
The zones only appear when you supply header or footer. A sidebar with neither
renders its slot exactly as before, so adding this feature changes nothing about an
existing sidebar — but it also means you cannot get the scroll region by setting a
height alone.
The head and foot keep their height while the middle shrinks. Building this by hand is
where it usually goes wrong: a flex item's automatic minimum size is its content, so
without min-height: 0 on the middle it refuses to shrink, the column grows past the
sidebar, and the head and foot scroll away with the list. The symptom reads as the
scroll region is missing when in fact everything scrolled.
Driving the Collapse From Outside
The collapse toggle renders inside the sidebar. toggle="start" moves it to the top,
and toggle="none" omits it so you can put your own trigger anywhere — in a topbar,
in a command palette, on a keyboard shortcut.
none keeps the state here. That matters, because a trigger outside the sidebar cannot
reach its Alpine scope: Alpine merges scope downwards only, so a button in your topbar
is not in the sidebar's tree and can never call its toggle(). Dispatch a window event
instead:
{{-- 1. The sidebar keeps the state, the rail and the persisted flag — but draws no toggle. --}}
<x-wirekit::sidebar id="app-nav" variant="flush" collapsible toggle="none" persist="nav">
<x-wirekit::sidebar.item href="#" icon="dashboard">Dashboard</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
{{-- 2. Your own trigger, anywhere on the page. `collapsed` starts false and follows
the sidebar's own announcement, so aria-expanded is correct from first paint. --}}
<div x-data="{ collapsed: false }"
x-on:wirekit:sidebar:toggled.window="if ($event.detail.id === 'app-nav') collapsed = $event.detail.collapsed">
<x-wirekit::button
x-on:click="$dispatch('wirekit:sidebar:toggle', { id: 'app-nav' })"
x-bind:aria-expanded="collapsed ? 'false' : 'true'"
aria-controls="app-nav"
intent="neutral"
surface="ghost"
>Toggle navigation</x-wirekit::button>
</div>
Here it is running. Two sidebars, one id each, and a trigger per sidebar — clicking
one leaves the other alone, which is the whole point of addressing them.
Two events, and the pair is deliberate:
| Event | Direction | Detail |
|---|---|---|
wirekit:sidebar:toggle |
you → the sidebar | { id } addresses one sidebar; omit id and every sidebar on the page responds, which is the right default for the single-sidebar case |
wirekit:sidebar:toggled |
the sidebar → you | { id, collapsed }, fired on every change and once on init |
The init firing is what makes an outside trigger accessible. Your button has to expose
aria-expanded, it cannot read the sidebar's internal state, and without an announcement
on arrival it would either report the wrong value until the first click or have to reach
into an attribute the component owns.
Width & Layout
The sidebar has a default width of 16rem (256px). Control it with Tailwind classes:
<x-wirekit::sidebar class="w-72">…</x-wirekit::sidebar>
In a typical app layout, pair the sidebar with a flex container:
<div class="flex min-h-screen">
<x-wirekit::sidebar class="w-64 shrink-0">…</x-wirekit::sidebar>
<main class="flex-1 p-6">…</main>
</div>
Icon Usage (Code)
<x-wirekit::sidebar.item href="/dashboard" :active="true">
<x-slot:icon>
<x-wirekit::icon name="home" class="w-4 h-4" />
</x-slot:icon>
Dashboard
</x-wirekit::sidebar.item>
The icon slot is decorative (aria-hidden="true"); the link text is the accessible name.
Owning the Scrolling Middle
Supplying a header or a footer turns the sidebar's body into three zones: a head that stays put, a scrolling middle, and a foot that stays put. The middle carries a fading shadow at whichever edge still has content beyond it — a scrollbar alone is easy to miss on a track that fades when idle.
If you have your own edge treatment, scroll-shadows="false" removes ours and keeps everything else:
{{-- 1. The zones stay: the brand row and the account row remain pinned. --}}
<x-wirekit::sidebar :scroll-shadows="false">
<x-slot:header>…</x-slot:header>
{{-- 2. Still a scroll region, still keyboard reachable. Only the two
shadow overlays and their sentinels are gone. --}}
<x-wirekit::sidebar.item href="/inbox" icon="inbox">Inbox</x-wirekit::sidebar.item>
<x-slot:footer>…</x-slot:footer>
</x-wirekit::sidebar>
The scroll region is marked data-wk-sidebar-scroller in both shapes, so your own affordance has something stable to attach to rather than a utility class.
Why this is its own prop. The shadows and the zones used to be one thing, so the only way to be rid of the shadows was to drop header and footer — giving up the sticky head and foot as well, which is a completely unrelated capability. One answer was deciding two questions.
Selection Mode
A sidebar is a list of links by default: every row is its own tab stop, Enter follows it,
and the row for the current page is marked aria-current="page". That is correct whenever
clicking a row changes the URL.
It is wrong when the choice stays on the client. aria-current="page" then announces a page
the reader is not on. Pass mode="selection" and the column carries the WAI-ARIA listbox
pattern instead.
Click a row, then use ↑ ↓ Home End — the marker moves and the focus ring stays on the column.
That is the pattern working, not a bug: a listbox is one control, so it holds one tab
stop and moves an aria-activedescendant marker inside itself.
What changes, and what does not
mode="navigation" (default) |
mode="selection" |
|
|---|---|---|
| Container | <nav> landmark |
role="listbox", one tab stop |
| Rows | <a href>, each its own tab stop |
role="option", not focusable |
| The chosen row | aria-current="page" |
aria-selected="true" |
| Keyboard | Tab between rows, Enter follows | ↑ ↓ Home End move, Enter and Space choose |
| Row identity | href |
value |
No aria-current is emitted in selection mode, and that is the point rather than an
omission. Announcing a page you are on and a value you have picked are two different claims;
a column making both is wrong about one of them.
The default is untouched. Every sidebar that exists today renders exactly what it rendered before — this is an opt-in second contract, not a change to the first.
Two things selection mode refuses, both on purpose
An href on an option must not be used and is dropped: a link inside a listbox puts the
row back in the tab order and makes Enter navigate instead of choose, which breaks the
one-tab-stop promise through a row you thought was decorative. Use value plus your own
click handler.
mode="selection" and collapsible cannot be combined, and the sidebar falls back to
navigation when you try. A collapsed rail hides every label, and a listbox whose options have
no visible names is a control nobody can use — while aria-selected would still be
announced, so it would read as usable to a screen reader and be unusable on screen.
Both say so in the browser console while app.debug is on, rather than failing quietly.
Props
<x-wirekit::sidebar>
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
'card' | 'flush' |
'card' |
Surface shape. card is the self-contained panel — background, border on all four sides, rounded — meant to sit inside a padded column. flush is the full-bleed navigation column: no radius, no surrounding border, the surface inherited from the host, and a single logical inline-end edge separating it from the content. |
tone |
'default' | 'muted' | 'inverse' |
'default' |
The surface, on the same --color-wk-rail-* roles the module rail reads. default is byte-identical to every existing sidebar. A toned column also re-points the generic neutral tokens for its own subtree, so the items, groups and disclosures inside it follow without knowing tones exist — see App Rail for the role table. There is no accent here on purpose: that tone inverts on hover, and a generic component paints its foreground and its hover surface from two different tokens it cannot switch together — the rail is safe there because its items switch both at once. Tone the rail and leave this column neutral, which is what the reference consoles do |
toggle |
'start' | 'end' | 'none' |
'end' |
Where the auto-rendered collapse toggle sits, or none to omit it and supply your own (see Driving the collapse from outside). Only meaningful with collapsible. |
collapsible |
bool |
false |
Enable the collapse-to-icon rail (adds an auto-rendered toggle) |
mode |
string |
navigation |
Which ARIA contract the column carries — navigation (a <nav> of links) or selection (one role="listbox" control). See Selection Mode |
selected |
string|null |
null |
The chosen value in selection mode, matched against each row's value. Rendered server-side, so the column is right before Alpine boots |
collapsed |
bool |
false |
Initial collapsed state (only with collapsible) |
persist |
string|null |
null |
localStorage key — remembers the collapsed state across reloads |
label |
string |
'Sidebar' |
Accessible name for the <nav> landmark. Override it when the page has more than one navigation landmark; passing aria-label / aria-labelledby directly on the component also wins over this default (no duplicate name is emitted) |
scrollShadows |
bool |
true |
The fading edge affordance on the scrolling middle. Set false to keep the zones and supply your own edge treatment — see Owning the scrolling middle |
zoneInset |
bool |
true |
Whether the head and foot zones inset their content to line up with the navigation. Set false when that content pads itself — a sidebar.item placed in a zone otherwise carries its own padding on top of the zone's and sits visibly right of the items it belongs with |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::sidebar.group>
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
Section heading (also used as accessible name) |
collapsible |
bool |
false |
Turn the heading into a disclosure button that folds the group's items |
open |
bool |
true |
Initial expanded state (only with collapsible) |
persist |
string|null |
null |
localStorage key — remembers the folded state across reloads (only with collapsible) |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::sidebar.collapsible>
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
'' |
Trigger text |
icon |
string|slot |
— | Icon name (resolved via the icon system, e.g. icon="cube") or a custom <x-slot:icon> |
open |
bool |
false |
Whether the group starts expanded |
persist |
string|null |
null |
localStorage key — remembers the expanded state across reloads |
variant |
'default' | 'heading' |
'default' |
Trigger style. default looks like a nav item; heading renders a small uppercase tracked section label (matching a collapsible sidebar.group) |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::sidebar.item>
| Prop | Type | Default | Description |
|---|---|---|---|
href |
string |
'#' |
Link destination |
value |
string|null |
null |
The row's identity when the parent sidebar is in selection mode. Ignored in the default navigation mode, where href is the identity |
active |
bool |
false |
Highlights as current page (sets aria-current="page") |
icon |
string|slot |
— | Icon name (resolved via the icon system, e.g. icon="cube") or a custom <x-slot:icon> |
submenu |
bool |
false |
Shows a chevron indicating a sub-navigation exists |
badge |
string|int|null |
null |
A trailing counter/dot (an unread count) rendered as a pill after the label, outside the truncating span so it is never clipped |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
sidebar |
<nav aria-label="Sidebar"> container |
sidebar.group |
Cluster of related items with optional label |
sidebar.collapsible |
Expandable/collapsible group with toggle trigger |
sidebar.item |
Single navigation link |
Accessibility
- Container:
<nav aria-label="Sidebar">landmark — override the name with thelabelprop (or a directaria-label/aria-labelledby) when the page has more than one navigation landmark, so assistive tech can tell them apart - Each group:
role="group"witharia-labelwhen a label is provided - Collapsible trigger:
aria-expandedtoggles between"true"and"false" - Active item:
aria-current="page" - Icons, chevrons, submenu indicators:
aria-hidden="true"(decorative) - Collapsible uses
x-collapsefor smooth height animation respectingprefers-reduced-motion
Entry height on touch
Sidebar entries are full-width and 32px tall. That clears the 24×24 minimum of WCAG 2.5.8 on both axes but stays under the 44 of the AAA criterion 2.5.5, which is a density choice: a navigation list is read as a list, and 44px rows fit a third fewer entries above the fold.
Entries stack vertically with no gap, so raising the hit area alone would make each
entry overlap its neighbor. Raise the row height instead — a scope override on the
vertical padding tokens moves the visible row and its target together.
Keyboard Interaction
This component is a layout wrapper. Keyboard interaction is delegated to its children.
Pitfalls
- Don't render a sidebar without
<x-wirekit::sidebar.toggle>for the mobile breakpoint. Without the toggle, the sidebar is permanently visible on narrow viewports and crushes the main content. - Don't bind
wire:model.liveon sidebar collapse/fold state. Each toggle round-trips to the server. Keep it client-only — the collapse-to-rail and collapsible groups manage their own state in Alpine, and pass apersist="key"prop to remember it inlocalStorageacross reloads (<x-wirekit::sidebar persist="…">,<x-wirekit::sidebar.group collapsible persist="…">,<x-wirekit::sidebar.collapsible persist="…">).
Design Tokens
| Token | Used for |
|---|---|
--font-wk-body-weight / --font-wk-heading-weight |
Item / group label weight |
--text-wk-xs / --text-wk-sm |
Group label / item font size |
--color-wk-text |
Active item text |
--color-wk-text-muted |
Inactive item text |
--color-wk-text-subtle |
Group label text |
--color-wk-bg-elevated |
Sidebar background |
--color-wk-bg-muted / --color-wk-bg-subtle |
Item hover + active background |
--color-wk-border |
Sidebar outer border |
--color-wk-ring |
Focus ring |
--ring-wk-width |
Focus ring width |
--border-wk-width |
Border width |
--radius-wk-sm / --radius-wk-md / --radius-wk-lg |
Item + sidebar radius |
--padding-wk-x-sm / --padding-wk-x-md / --padding-wk-y-sm |
Item padding |
--transition-wk-duration |
Hover / active transition |
Further Reading
- MDN:
<nav>element — semantic navigation landmark - MDN:
aria-current - WAI-ARIA Landmark Regions
- Using ARIA: role=navigation