Sidebar
The <x-wirekit:: 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::instead. See App Shell for the integrated layout walk-through.app-shell>
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.
In the app shell's drawer
Inside an app shell, below the lg breakpoint
the sidebar becomes the navigation drawer. There it always shows its names, even when the reader
collapsed it at the desktop: the drawer is opened to be read, and on a touch screen a tap on an
icon navigates before any tooltip could name it. The choice itself is kept. Nothing is written to
the stored value, and back above the breakpoint the column is drawn collapsed again. The collapse
control is not shown in the drawer, where it would change nothing on screen.
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, leaves the column as it is without it — 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 in the example above. 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.
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. 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. 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 Intersection, 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 isOpen is in scope, so you decide when it shows. x-show="! isOpen" 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.
open is deprecated and goes away in 3.0Until 2.56 this state was called open, and markup that reads open keeps working through 2.x:
it reads and writes the same value. Move to isOpen before 3.0. When a scope is missing, open
silently resolves to the browser's window.open instead of failing, while isOpen fails with its
own name.
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:: 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, and a demo built that way renders nothing. 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:
An Action in the Sidebar
A row is a link by default, because a navigation entry leads somewhere. An entry that does something instead, such as signing out, is a button: as="button" renders the row as a <button> with the look, the hover, the focus ring and the rail behavior of the rows beside it. A screen reader announces it as a button, and in a collapsed rail its name shows in the tooltip as on every other row:
The button takes its type from the tag and is type="button" without one, so it never submits a form by accident. Signing out posts, so the row sits in a form and says type="submit":
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-wirekit::sidebar.item as="button" type="submit" icon="sign-out">Sign out</x-wirekit::sidebar.item>
</form>
A button has no address, so href, target and rel are not written on it.
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:: 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). The prop belongs to the group: it folds the section, and the rows below it carry the rail's tooltip only when the sidebar itself is collapsible.
The one place to share a key is the same section drawn twice, such as a navigation that appears in a drawer on phones and in a rail on wider screens. Groups with one key stay in step on the page: folding one folds the other, without a reload.
This is the section-heading counterpart to <x-wirekit:: (which looks like a nav item with an icon). sidebar. also accepts persist now, so either affordance can remember its state:
A stored state wins over open, so a section a reader folded stays folded on the next visit. For the
group that holds the page they are on, add force-open: it opens that group on every load whatever
was stored, and a fold still holds for the rest of the visit. Tie it to the route, the way the open
state usually is:
{{-- 1. Remembered per group, and open wherever the reader currently is --}}
<x-wirekit::sidebar.group label="Orders" collapsible persist="nav-orders" :force-open="request()->routeIs('orders.*')">
<x-wirekit::sidebar.item href="/orders">All orders</x-wirekit::sidebar.item>
</x-wirekit::sidebar.group>
A section the server renders open paints with the rest of the page; only a closed one waits for
Alpine, so it never shows its entries for a frame. With persist, a small script right after the
section applies the stored state before the first paint, so a group the reader opened or folded
on an earlier visit appears that way at once instead of changing when Alpine starts. Under a
Content-Security-Policy the script carries the nonce WireKit reads for its other inline scripts.
If it is blocked, the section changes when Alpine starts, as it did before.
Collapsible sections in the icon rail
When a collapsible sidebar folds to its icon rail, a <x-wirekit:: 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:: 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 without zones, so 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 the control can stand somewhere else: in the foot of a
rail, in a topbar.
<x-wirekit:: is that control, standing anywhere. It draws the
sidebar's own glyph and carries the same name, so the two read as one button in two
places. The state stays in the sidebar: the button asks it to fold and shows whatever
the sidebar reports back.
In an app shell the usual place is the foot of the rail:
{{-- 1. The sidebar draws no control of its own, and remembers the choice in a cookie. --}}
<x-slot:sidebar>
<x-wirekit::sidebar id="app-nav" variant="flush" collapsible toggle="none" persist="app-nav" persist-driver="cookie">
<x-wirekit::sidebar.item href="#" icon="home">Dashboard</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
</x-slot:sidebar>
{{-- 2. The control in the rail's foot. The same persist props as the sidebar, so its first
paint already shows the stored choice; its name appears to the right, clear of the rail. --}}
<x-slot:rail>
<x-wirekit::app-rail>
<x-wirekit::app-rail.item href="#" icon="home" label="Home" />
<x-slot:footer>
<x-wirekit::sidebar.collapse-toggle for="app-nav" persist="app-nav" persist-driver="cookie" tooltip-placement="right" />
</x-slot:footer>
</x-wirekit::app-rail>
</x-slot:rail>
Below the shell's lg breakpoint the button is not shown. There the sidebar is the
navigation drawer, which shows its names whatever the reader chose, so there is nothing to
fold. A sidebar outside an app shell folds at every width; give its control always.
for names the sidebar by its id. Left out, the button folds every sidebar on the page
and follows whichever one reports, which is right for a page with a single sidebar.
Your own trigger
For a trigger of a different shape, a command palette entry or a keyboard shortcut, speak
to the sidebar directly. toggle="none" keeps the state in the sidebar, and a trigger
outside it 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, chosenCollapsed, cause }, fired on every change and once on init. cause says which: init, toggle when the reader folds or opens the column, or viewport when a resize moves it into or out of the app shell's drawer. collapsed is what the column shows, chosenCollapsed what the reader chose; the two differ in the drawer, which names its entries whatever was chosen |
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.
Keeping the choice on your server
persist keeps the choice in the browser, so it follows the device rather than the person. To
keep it per person, render the sidebar with the person's choice in collapsed and save it when
the reader folds or opens the column. Save on cause === 'toggle' and on nothing else: the
announcement on arrival and a resize change no choice, and saving on them would write on every
page load. Save chosenCollapsed rather than collapsed, which is what the column shows and
stays open in the drawer:
<div x-on:wirekit:sidebar:toggled.window="if ($event.detail.id === 'app-nav' && $event.detail.cause === 'toggle') $wire.rememberNavigation($event.detail.chosenCollapsed)">
<x-wirekit::sidebar id="app-nav" collapsible :collapsed="$navigationCollapsed">
<x-wirekit::sidebar.item href="/dashboard" icon="home">Dashboard</x-wirekit::sidebar.item>
</x-wirekit::sidebar>
</div>
$navigation and remember belong to your Livewire component, which reads
the choice from where you keep it and writes it back there. An explicit collapsed pins the
first render, so the browser's store is not asked.
Width & Layout
The sidebar sets no width of its own. It is a flex column that fills whatever box you put it in, so a width is something you give it — which is why every preview on this page carries one. Control it with Tailwind classes:
<x-wirekit::sidebar class="w-72">…</x-wirekit::sidebar>
The one width the component does set is the COLLAPSED rail: a collapsible sidebar emits
w-[var(--size-wk-rail,3.25rem)] for its collapsed state, so the token is what to override
there rather than a class.
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" size="sm" />
</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. Were the shadows and the zones one thing, the only way to be rid of the shadows would be to drop header and footer, giving up the sticky head and foot as well, a completely unrelated capability: one answer would decide 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. A row that leads a section the page lies in, such as a catalog
whose detail page is open, is not the page itself: current="location" marks it as the current
location instead, with the same highlight.
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. The marker is an inset ring on
screen; under forced colors, which drops the ring, it is a dashed frame in the system's
selection color, inside the solid frame of the chosen row when the two fall on one row.
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.
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 control draws a panel whose edge stands on the left, with an arrow pointing 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 was — 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 arrow 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 |
defaultCollapsed |
bool |
false |
The state on a first visit, before the store has anything to say. Unlike collapsed it does not turn the seeding off — a stored preference still wins, under either driver. Reach for it when you want "collapsed the first time, then follow the reader" |
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) |
force-open |
bool |
false |
Open on every load, whatever persist stored — for the group that holds the current page. A fold still holds for the rest of the visit (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 |
force-open |
bool |
false |
Open on every load, whatever persist stored — for the section that holds the current page. A fold still holds for the rest of the visit |
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.collapse-toggle>
| Prop | Type | Default | Description |
|---|---|---|---|
for |
string|null |
null |
The id of the sidebar the button folds. Left out, it folds every sidebar on the page |
side |
'start' | 'end' |
'start' |
The side the sidebar stands on, the value passed to the sidebar's own side. Turns the arrow the way the sidebar's own control turns it |
collapsed |
bool|null |
null |
What to show before the sidebar has reported its state. Pass the sidebar's own value |
defaultCollapsed |
bool |
false |
The first-visit state, as on the sidebar |
persist |
string|null |
null |
The sidebar's storage key. With the cookie driver the button reads the stored choice while the page renders |
persistDriver |
string |
'local' |
local or cookie, as on the sidebar |
always |
bool |
false |
Show the button at every width. By default it is hidden below the app shell's lg breakpoint, where the sidebar sits in the drawer and shows its names |
tooltipPlacement |
string|null |
null |
Where the button's name appears on hover. Left out, the tooltip's configured placement |
scope |
string|null |
null |
Scoped personalization key |
The four state props only decide the first paint: once the sidebar starts, it reports its state and the button follows it.
<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 unless current says otherwise) |
current |
string |
'page' |
What the active row is to a screen reader: page for the page itself, location for a row that leads the section the page lies in, such as a catalog whose detail page is open, true for neither. The highlight is the same for all three |
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 |
as |
string |
'a' |
The element: 'a', a link, or 'button', an action such as signing out. A button takes type from the tag, button unless it says submit or reset — see An Action in the Sidebar. Ignored in selection mode |
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 |
sidebar.collapse-toggle |
The sidebar's collapse control, placed outside the sidebar |
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.carries the same pair, so its items are announced as one named section instead of a button followed by unrelated links — passcollapsible aria-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.behave 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 acollapsible roleon 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", orlocationortrueascurrentnames it. Under forced colors, where its tint is not painted, the active item, or the chosen row in selection mode, is framed in the system's selection color - 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
- Inside
<x-wirekit::, don't render a sidebar withoutapp-shell> <x-wirekit::for the mobile breakpoint. Without the toggle, the sidebar is permanently visible on narrow viewports and crushes the main content. The scope matters: the toggle's click handler issidebar. toggle> sidebarOpen = !sidebarOpen, andsidebarOpenlives in the app-shell'sx-data. Beside a standalone sidebar there is no such variable, so the button renders, looks operable, and does nothing. A standalone sidebar is toggled by dispatchingwirekit:sidebar:togglewith the sidebar'sid— see the section above for the full pattern, including thearia-expandedthat follows the sidebar's own announcement. - Inside Livewire's
@persist, the sidebar keeps the current item it was rendered with. Livewire carries a persisted element to the next page as it is, so after awire:navigatevisitactive, its classes andaria-current="page"still mark the page you left. Livewire'swire:currentmoves classes, notaria-current, so a screen reader still names the old page. Render the sidebar on each page instead of persisting it. - 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-active |
Item hover and the current item's background |
--color-wk-bg-hover |
Toggle hover 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