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.
Remembering the width without a jump
persist stores the reader's choice in localStorage, which no server can read — so the markup
carries the seed width. WireKit closes that gap for you: a small script emitted directly after
the column 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 (see below), or a browser with site storage switched off, puts you back on the old behavior — the column renders at its seed width and corrects itself once Alpine boots, which on a page where it was collapsed 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::sidebar collapsible persist="app-nav" persist-driver="cookie" />
An explicit collapsed always wins — pin the state and the stored value is ignored.
EncryptCookies: add the key to the exception list
The sidebar 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 sidebar 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.
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.
A column on the right
A sidebar is usually the leftmost column, and two of its details follow from that: the separator sits on its right edge, and the collapse chevron points left, the way the column is about to move. Put the same component to the RIGHT of the content — a details panel beside a reading column — and both are backwards: the rule lands on the page margin instead of the boundary it should mark, and the arrow points into the panel it is closing.
side="end" mirrors them together:
<x-wirekit::sidebar variant="flush" collapsible side="end" label="Message details">
<x-wirekit::sidebar.item href="#" icon="user">Alice Johnson</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
side and toggle answer different questions and are not interchangeable. toggle says
where the CONTROL sits inside the column; side says which side of the content the
column is on. A layout can legitimately put the control at the inline start of a column
that is itself on the left — which is why the side cannot be inferred from it.
Your own content in a collapsed rail
sidebar.item already knows what to do when the column narrows: the label goes
screen-reader-only, and the icon carries the row. Content you put in the header or
footer slot — a brand name, a workspace switcher, a "Test mode" label — has no way to
join in on its own, and what it does instead is wrap. A word broken across two lines
makes that row the tallest thing in a column of 32 px icons.
Add wk-rail-hide to the part that should step aside:
<x-slot:header>
<x-wirekit::shell-bar padding="sm" bleed>
<x-wirekit::avatar initials="AC" size="sm" />
<x-wirekit::text weight="semibold" class="wk-rail-hide">Acme Console</x-wirekit::text>
</x-wirekit::shell-bar>
</x-slot:header>
The words leave the layout and stay in the accessible name, so the row keeps the height of the icon beside it and a screen reader still announces the brand. Outside a collapsed sidebar the class does nothing at all, so it is safe on content that appears in both places.
Mark the label, not the row. Hiding the whole shell-bar would take the avatar with
it and leave a gap where the brand used to be — the icon is what keeps the rail readable.
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 |
side |
'start' | 'end' |
'start' |
Which side of the content the column stands on. Decides where the separator is drawn and which way the collapse chevron points — both belong on the edge that faces the content. Set end for a details panel to the right of the reading column. |
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|null |
null |
Initial collapsed state (only with collapsible). Left unset, the cookie driver may answer it instead; an explicit value always wins |
persist |
string|null |
null |
Storage key — remembers the collapsed state across reloads |
persistDriver |
string |
'local' |
Where that 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 Remembering the width without a jump |
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. A namedsidebar.collapsiblecarries the same pair, so its items are announced as one named section instead of a button followed by unrelated links — passaria-labeloraria-labelledbydirectly to override the name, or to name a section whoselabelis empty - A section with no name at all gets no role.
sidebar.groupandsidebar.collapsiblebehave alike here: a group role that nothing can name announces a boundary and then cannot say what the boundary is for, so neither emits one. Pass aroleon the tag yourself if you want it anyway — an explicit value always wins - Collapsible trigger:
aria-expandedtoggles between"true"and"false", andaria-controlsnames the region the trigger discloses. An unlabeled trigger falls back to a generic accessible name, so the control is never announced as a bare button - The auto-rendered collapse toggle of a
collapsiblesidebar carries its name and its expanded state in the server-rendered HTML, not only in the Alpine binding — so it is named before the page becomes interactive, and to any check that reads the served markup - 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