Skip to main content
Copy for LLM

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

Sidebar with Groups

Without Groups

If you don't need logical sections, drop items directly into the sidebar:

Flat Sidebar

With Icons

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 icon rail — click the toggle

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.

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

Sidebar with Collapsible Sub-Groups

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.

A shadow marks the edge you have not reached yet

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

Until 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 collapsed group that still says something is waiting

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:

A dot instead of a count, when the total would claim more than it knows

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

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:

Items with Submenu Indicators

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:

A sign-out row at the foot of the sidebar

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:

Items with Unread Counters

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.

Counters in a collapsible rail — click the toggle

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). 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::sidebar.collapsible> (which looks like a nav item with an icon). sidebar.collapsible 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 groups with persistence

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.

Collapsible section flattens in the icon rail — click the toggle

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:

Collapsible with a section-heading trigger

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:

Flush column against a page surface

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:

Brand pinned top, account pinned bottom, only the list scrolls

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::sidebar.collapse-toggle> 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.

The collapse control outside the sidebar

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 sidebars, addressed by id

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>

$navigationCollapsed and rememberNavigation() 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.

Selection Column
Production
Staging
Preview

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.

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 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 the label prop (or a direct aria-label / aria-labelledby) when the page has more than one navigation landmark, so assistive tech can tell them apart
  • Each group: role="group" with aria-label when a label is provided. A named sidebar.collapsible carries the same pair, so its items are announced as one named section instead of a button followed by unrelated links — pass aria-label or aria-labelledby directly to override the name, or to name a section whose label is empty
  • A section with no name at all gets no role. sidebar.group and sidebar.collapsible 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 a role on the tag yourself if you want it anyway — an explicit value always wins
  • Collapsible trigger: aria-expanded toggles between "true" and "false", and aria-controls names 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 collapsible sidebar 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", or location or true as current names 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-collapse for smooth height animation respecting prefers-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::app-shell>, 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. The scope matters: the toggle's click handler is sidebarOpen = !sidebarOpen, and sidebarOpen lives in the app-shell's x-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 dispatching wirekit:sidebar:toggle with the sidebar's id — see the section above for the full pattern, including the aria-expanded that 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 a wire:navigate visit active, its classes and aria-current="page" still mark the page you left. Livewire's wire:current moves classes, not aria-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.live on 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 a persist="key" prop to remember it in localStorage across 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

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store