Skip to main content
WireKit
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

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

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

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

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

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.

What changes, and what does not

mode="navigation" (default) mode="selection"
Container <nav> landmark role="listbox", one tab stop
Rows <a href>, each its own tab stop role="option", not focusable
The chosen row aria-current="page" aria-selected="true"
Keyboard Tab between rows, Enter follows ↑ ↓ Home End move, Enter and Space choose
Row identity href value

No aria-current is emitted in selection mode, and that is the point rather than an omission. Announcing a page you are on and a value you have picked are two different claims; a column making both is wrong about one of them.

The default is untouched. Every sidebar that exists today renders exactly what it rendered before — this is an opt-in second contract, not a change to the first.

Two things selection mode refuses, both on purpose An href on an option must not be used and is dropped: a link inside a listbox puts the row back in the tab order and makes Enter navigate instead of choose, which breaks the one-tab-stop promise through a row you thought was decorative. Use value plus your own click handler.

mode="selection" and collapsible cannot be combined, and the sidebar falls back to navigation when you try. A collapsed rail hides every label, and a listbox whose options have no visible names is a control nobody can use — while aria-selected would still be announced, so it would read as usable to a screen reader and be unusable on screen.

Both say so in the browser console while app.debug is on, rather than failing quietly.

Props

<x-wirekit::sidebar>

Prop Type Default Description
variant 'card' | 'flush' 'card' Surface shape. card is the self-contained panel — background, border on all four sides, rounded — meant to sit inside a padded column. flush is the full-bleed navigation column: no radius, no surrounding border, the surface inherited from the host, and a single logical inline-end edge separating it from the content.
tone 'default' | 'muted' | 'inverse' 'default' The surface, on the same --color-wk-rail-* roles the module rail reads. default is byte-identical to every existing sidebar. A toned column also re-points the generic neutral tokens for its own subtree, so the items, groups and disclosures inside it follow without knowing tones exist — see App Rail for the role table. There is no accent here on purpose: that tone inverts on hover, and a generic component paints its foreground and its hover surface from two different tokens it cannot switch together — the rail is safe there because its items switch both at once. Tone the rail and leave this column neutral, which is what the reference consoles do
toggle 'start' | 'end' | 'none' 'end' Where the auto-rendered collapse toggle sits, or none to omit it and supply your own (see Driving the collapse from outside). Only meaningful with collapsible.
collapsible bool false Enable the collapse-to-icon rail (adds an auto-rendered toggle)
mode string navigation Which ARIA contract the column carries — navigation (a <nav> of links) or selection (one role="listbox" control). See Selection Mode
selected string|null null The chosen value in selection mode, matched against each row's value. Rendered server-side, so the column is right before Alpine boots
collapsed bool false Initial collapsed state (only with collapsible)
persist string|null null localStorage key — remembers the collapsed state across reloads
label string 'Sidebar' Accessible name for the <nav> landmark. Override it when the page has more than one navigation landmark; passing aria-label / aria-labelledby directly on the component also wins over this default (no duplicate name is emitted)
scrollShadows bool true The fading edge affordance on the scrolling middle. Set false to keep the zones and supply your own edge treatment — see Owning the scrolling middle
zoneInset bool true Whether the head and foot zones inset their content to line up with the navigation. Set false when that content pads itself — a sidebar.item placed in a zone otherwise carries its own padding on top of the zone's and sits visibly right of the items it belongs with
scope string|null null Scoped personalization key

<x-wirekit::sidebar.group>

Prop Type Default Description
label string|null null Section heading (also used as accessible name)
collapsible bool false Turn the heading into a disclosure button that folds the group's items
open bool true Initial expanded state (only with collapsible)
persist string|null null localStorage key — remembers the folded state across reloads (only with collapsible)
scope string|null null Scoped personalization key

<x-wirekit::sidebar.collapsible>

Prop Type Default Description
label string '' Trigger text
icon string|slot Icon name (resolved via the icon system, e.g. icon="cube") or a custom <x-slot:icon>
open bool false Whether the group starts expanded
persist string|null null localStorage key — remembers the expanded state across reloads
variant 'default' | 'heading' 'default' Trigger style. default looks like a nav item; heading renders a small uppercase tracked section label (matching a collapsible sidebar.group)
scope string|null null Scoped personalization key

<x-wirekit::sidebar.item>

Prop Type Default Description
href string '#' Link destination
value string|null null The row's identity when the parent sidebar is in selection mode. Ignored in the default navigation mode, where href is the identity
active bool false Highlights as current page (sets aria-current="page")
icon string|slot Icon name (resolved via the icon system, e.g. icon="cube") or a custom <x-slot:icon>
submenu bool false Shows a chevron indicating a sub-navigation exists
badge string|int|null null A trailing counter/dot (an unread count) rendered as a pill after the label, outside the truncating span so it is never clipped
scope string|null null Scoped personalization key

Sub-Components

Component Purpose
sidebar <nav aria-label="Sidebar"> container
sidebar.group Cluster of related items with optional label
sidebar.collapsible Expandable/collapsible group with toggle trigger
sidebar.item Single navigation link

Accessibility

  • Container: <nav aria-label="Sidebar"> landmark — override the name with 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
  • Collapsible trigger: aria-expanded toggles between "true" and "false"
  • Active item: aria-current="page"
  • 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

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

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store