Sidebar

Composable app-shell navigation.

Usage

Custom rather than a Bits wrapper — Bits ships no sidebar, and there is no hard behaviour to buy here. What a sidebar needs is a landmark, a labelled list, a shared collapsed state and a toggle that announces itself, all of which the platform gives directly.

Page content

Toggle it — the labels hide, the items stay reachable, and "Reports" is a non-link.

Why Provider is separate

Provider owns the state; Root is the <aside>. They are separate parts for one concrete reason: Svelte context reaches descendants, not siblings.

Put the state on Root and a Trigger sitting in a top bar next to the sidebar never sees it — and with collapsible="offcanvas" the Trigger has to be outside, or collapsing hides the only way back. A test caught exactly that: the Trigger got a null aria-controls and clicking it did nothing.

The Provider imposes no layout unless you pass shell. By default it is display: contents, so it is invisible to layout and the children sit where they would without it — which matters when the sidebar is a child of a grid your app already defined. shell opts into the flex row.

Collapse modes

  • icon — narrows to a rail. Labels hide, items stay reachable.
  • offcanvas — slides out entirely, and its contents become unfocusable so the cursor cannot vanish into a hidden panel.
  • none — not collapsible; the Trigger becomes pointless.

collapsed is bindable on the Provider. Persist it — a sidebar that forgets its state on every navigation is worse than one that never collapsed.

Size it with --sve-sidebar-width and --sve-sidebar-width-icon rather than overriding rules.

No JS media query

The collapsed presentation is one CSS class. This component does not watch the viewport.

A library that swaps the markup for a drawer below some breakpoint hardcodes that breakpoint inside itself, cannot know it during server rendering, and flashes the wrong layout on hydration. collapsible="offcanvas" plus your breakpoint gets the same result, and your app decides when it happens.

Accessibility

  • Root is a named <aside> — an app shell usually has more than one complementary region.
  • Point each Group's aria-labelledby at its GroupLabel id. A visual heading assistive technology cannot connect to its items is decoration, not structure.
  • On an icon rail the GroupLabel is hidden visually, not removed — deleting it would strip the group of its name for someone who has plenty of room for it.
  • Give icon-only Items a label. It becomes the accessible name when collapsed; without it the rail is a column of unnamed links.
  • Item sets aria-current="page" when active — not just a highlight, which tells sighted users and nobody else.
  • disabled renders a <span>, not a link. A link that goes nowhere takes a tab stop and lies about what will happen.
  • The Trigger's label stays the same in both states. aria-expanded already carries the state; a label that flips to "Close" says it twice and contradicts itself mid-announcement.

Not a docs sidebar

This is an app-shell panel: fixed header and footer, a scrolling middle, and an icon rail when collapsed. It is not the right shape for a documentation table of contents — like the one on the left of this page — which is a sticky list that becomes a disclosure panel on mobile.

We tried rebuilding this site's own navigation on top of it, as a test. Making it fit meant overriding display, flex-direction, width, border, background, overflow and the collapse mechanism itself — nearly everything the component provides. When you override that much you are fighting the component, not using it.

The exercise was still worth it: it is what surfaced the display: contents default on Provider and the disabled variant on Item, both of which are here because of it.

Props

Sidebar.Providercollapsed is bindable.

PropTypeDefault
collapsed Whether the sidebar is collapsed. Bindable, so the app can persist it — a sidebar that forgets its state on every navigation is worse than one that never collapsed.booleanfalse
collapsible How collapsing presents itself. `icon` — narrows to a rail; labels hide, items stay reachable. `offcanvas` — slides out of view entirely. `none` — not collapsible; the Trigger becomes pointless.Collapsible'icon'
side Which edge the sidebar is anchored to.Side'left'
shell Lay the children out as an app shell — a flex row with the sidebar beside the main content (reversed when `side="right"`). OFF by default, and deliberately so: this component's job is state, and a provider that silently imposes a layout fights whatever the app already has. Without it the wrapper is `display: contents`, so it does not participate in layout at all and the children behave as if it were not there — which is what you want when the sidebar sits inside a grid the app already defined. Found by trying to rebuild this library's own docs navigation on top of this component: the forced flex row put the mobile toggle beside the panel instead of above it.booleanfalse
sidebarId Id given to the sidebar element, so a Trigger anywhere inside this provider can point `aria-controls` at it.string'sve-sidebar'
class Extra classes merged onto the shell.string
children Snippet

Sidebar.Root

PropTypeDefault
label Accessible name for the landmark. An app shell usually has more than one complementary region, so this is how a screen reader user tells them apart.string'Sidebar'
class Extra classes merged onto the root.string
children Snippet

Sidebar.Item — plus the native anchor attributes.

PropTypeDefault
active Marks the current page. It sets `aria-current="page"`, not just a highlight — styling it without that tells sighted users and nobody else.booleanfalse
label Accessible name used when the label is hidden on an icon rail. Required in practice for icon-only items: without it a collapsed sidebar becomes a column of unnamed links.string
disabled A visible entry that cannot be navigated to — a feature not shipped yet, a section the user lacks access to. It renders a `<span>` rather than an `<a>`, because a link that goes nowhere is worse than not being a link: it takes a tab stop and lies about what will happen. Found by rebuilding this library's own docs sidebar on top of this component, which needed exactly that for its "soon" entries.booleanfalse
class Extra classes merged onto the item.string
children Snippet

Sidebar.Trigger

PropTypeDefault
label Accessible name. It stays the SAME in both states — `aria-expanded` already carries whether the sidebar is open, so a label that flips between "Open" and "Close" says it twice and contradicts itself mid-announcement.string'Toggle sidebar'
class Extra classes merged onto the button.string
children Snippet

Header, Content, Footer, Group, GroupLabel and Menu each take class plus their native attributes.