Sidebar
A composable app-shell navigation rail. Desktop aside with icon-rail or off-canvas collapse, optional drag-resize, floating and inset shells; on phones a modal drawer on the Drawer spine with swipe-dismiss. Surface-ink inverse tone, full compound API, Cmd/Ctrl+B.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
SidebarProvider,
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarBrand,
SidebarSearch,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuBadge,
SidebarTrigger,
SidebarInset,
} from "@tessinaui/ui";<SidebarProvider label="Workspace">
<Sidebar>
<SidebarHeader>
<SidebarBrand logo="W" name="Workspace" description="Pro plan" />
<SidebarSearch placeholder="Search…" shortcut="⌘K" />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton active render={<a href="/home" />}>
<Home />
<span>Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="/inbox" />}>
<Inbox />
<span>Inbox</span>
<SidebarMenuBadge max={99}>128</SidebarMenuBadge>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* account row, plan widgets */}</SidebarFooter>
</Sidebar>
<SidebarInset>
<header>
<SidebarTrigger />
</header>
{/* page content */}
</SidebarInset>
</SidebarProvider>The provider owns open state and publishes the width CSS variables; sibling
Sidebar and SidebarInset build the two-column shell. Prefer
render={<a href/>} for rows that navigate — a real link keeps
open-in-new-tab and link semantics even with client routing.
Examples
Default
A basic dashboard shell with a brand header, nav menu, footer, and collapsible icon rail.
Shell variants
Switch between the sidebar, floating, and inset shell treatments.
Row variants
default, ghost, pill, and the inline-start accent indicator; the
destructive row is intent="error". Rows without icons keep their alignment.
Inverse
The always-dark pro-tool rail. intent="inverse" publishes the surface-ink
contract, so parts — and your own footer chrome — derive from
--surface-ink and hold in both themes.
Collapsed rail
With collapsible="icon" and defaultOpen={false} the sidebar starts as an
icon rail: rows keep real tooltips (hover and keyboard focus), and badge
counts fold into corner dots on the icons.
Rail flyout
While collapsed, a row with children opens its sub-list as a dropdown flyout — nested destinations stay reachable from the 64px rail.
Collapsible submenu
SidebarMenuItem collapsible + SidebarMenuToggle: the row button navigates,
the chevron discloses — expand/collapse and navigation never share one control.
Static submenu
Always-visible nested links under a parent item.
Badges and counts
Numeric counts with a max cap, tone pills, the dot form, and composed house
Badge chips for Beta/New — plus the collapsed corner-dot behavior.
Rich rows
Two-line description rows, live status, external links, plan-gated rows that
explain instead of disabling, shortcut hints, hover-revealed row actions, and
the destructive row.
Workspace switcher
The identity row opens a workspace menu — the most common sidebar header pattern in the wild.
Account footer
Profile row pinned to the footer, opening the account menu.
Settings shape
Back-to-app escape row, settings search, grouped sections, and a landmark label that names the destination set.
Plan widgets
Trial chip, usage meter and upgrade card composed in SidebarFooter; an empty
group teaching its purpose.
Dual rail
A thin entity rail beside a contextual channel panel — two providers, the
documented --sidebar-width override, and a single Cmd/Ctrl+B owner.
Resizable
resizable turns the edge rail into a real separator: drag it, or focus it and
use ←/→ (Home/End for the bounds). persistKey keeps width and open state
across reloads.
Mobile drawer
The phone branch rides the Drawer spine: modal dialog, family scrim, swipe-to-dismiss toward its edge, safe-area padding.
Skeleton
A loading placeholder via SidebarSkeleton — no provider required; the box
follows size and rounded.
When to use
- App shells with five or more destinations, or hierarchy worth grouping — dashboards, consoles, editors, mail.
- Persistent library/tree navigation — projects, folders, channels, playlists.
When not:
- A temporary panel that isn't the app's primary navigation →
Drawer. - Filtering the CURRENT view — a sidebar navigates between destinations;
filters belong to
Tabs, chips or a filter panel. - More than two levels of nesting: keep the sidebar to parent + one sub level and give deeper data an intermediate list view instead.
API Reference
SidebarProvider
Root state container. Publishes context plus the width CSS variables
(--sidebar-width, --sidebar-width-icon, --sidebar-width-mobile) on its
wrapper. Accepts any <div> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "sidebar" | "floating" | "inset" | "sidebar" | Shell treatment — flush, detached card, or shared-surface inset |
collapsible | "offcanvas" | "icon" | "none" | "icon" | What "closed" means — hide fully, icon rail, or pinned open |
side | "left" | "right" | "left" | Docked edge; mirrored automatically under dir="rtl" |
size | "sm" | "md" | "lg" | "md" | Expanded width preset — 240/288/320px |
intent | "default" | "inverse" | "default" | inverse is the always-dark rail on the surface-ink contract |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Shell radius; full caps at the panel scale (24px). The flush variant ignores it |
dir | "ltr" | "rtl" | "ltr" | Text and layout direction |
open / defaultOpen / onOpenChange | — | defaultOpen: true | Desktop open state, controlled or uncontrolled |
openMobile / onOpenMobileChange | — | — | Mobile drawer state, controlled or uncontrolled |
mode | "desktop" | "mobile" | auto | Force a render mode; auto switches at 768px via matchMedia |
keyboardShortcut | boolean | true | Cmd/Ctrl+B toggling — see Keyboard below |
label | string | "Sidebar" | Accessible name for the landmark and the mobile drawer. Name the destination set ("Mail folders"), skip the word "navigation" |
persistKey | string | — | Persist open state and resized width to localStorage under this key |
resizable | boolean | { min?, max? } | false | Let the rail drag-resize the expanded width. Defaults: min 180px, max half the viewport |
Custom widths — the presets are CSS variables, so any width is one style override away (this is also how multi-sidebar layouts size each rail):
<SidebarProvider style={{ "--sidebar-width": "22rem", "--sidebar-width-mobile": "22rem" }}>Flash-free SSR — persistKey restores on the client. For a server-rendered
first paint that already matches, mirror the value into a cookie and feed it
back as defaultOpen:
// app/layout.tsx (Next.js)
const open = (await cookies()).get("sidebar_state")?.value !== "false";
<SidebarProvider defaultOpen={open} onOpenChange={(o) => { document.cookie = `sidebar_state=${o}; path=/`; }}>Sidebar
The rail itself: an inline <aside role="navigation"> on desktop, a modal
drawer (Drawer spine, portalled) on mobile. Off-canvas collapse keeps the aside
mounted for the width transition but makes it inert, so nothing invisible
stays in the tab order.
| Prop | Type | Default | Description |
|---|---|---|---|
variant / side / intent | — | from provider | Rarely-needed per-instance overrides |
label | string | provider label | Override the landmark / drawer name |
description | string | — | Extra sr-only description for the mobile drawer dialog |
State attributes for styling from outside: data-state="expanded" \| "collapsed", data-variant, data-side, data-intent, data-mobile (mobile
drawer only), and on the provider wrapper data-collapsible (the mode, only
while collapsed). Every part carries data-slot.
// hide anything in the icon rail
<span className="group-data-[collapsible=icon]/sidebar-wrapper:hidden">Shortcuts</span>SidebarTrigger
Toggle button — wires aria-expanded and (on desktop) aria-controls to the
panel automatically. Icon follows the docked side and flips under RTL.
SidebarRail
Edge strip. By default a pointer-only click target that toggles the sidebar
(keyboard users have the trigger and Cmd/Ctrl+B); when the provider is
resizable it becomes a real separator — focusable, drag to resize, ←/→ steps,
Home/End to the bounds, click still toggles. While off-canvas-collapsed it
keeps a grabbable sliver outside the edge so the pointer can reopen a fully
hidden sidebar.
SidebarInset
<main> wrapper for page content. On variant="inset" it becomes the rounded
content card; it also resets the surface contract to the page, while the inset
wrapper publishes the wash via plateClasses.wash — wells and focus-ring gaps
inside the shell paint the real backdrop, not the page.
SidebarHeader / SidebarFooter / SidebarContent / SidebarSeparator
Layout zones. Only SidebarContent scrolls. Keep critical actions out of the
very bottom of a tall sidebar — offscreen window edges eat it; prefer spacing
groups over stacking separators.
SidebarBrand
Identity row: logo, name, description, trailing slots. With onClick
it renders a real <button> (menus bind to it directly); without, a static
<div>. Collapses to the logo tile in the icon rail.
SidebarSearch
Search well on the input-family focus model. In the icon rail it renders a
working icon button — pass onCollapsedClick (e.g. open your command
palette). shortcut renders keycap hints. Accepts all <input> props.
SidebarGroup / SidebarGroupLabel / SidebarGroupAction / SidebarGroupContent
Labelled section; collapsible makes the label a disclosure trigger
(aria-expanded + aria-controls, chevron, animated panel that stays
searchable but inert while closed). SidebarGroupLabel takes count;
SidebarGroupAction is the small trailing action (e.g. a +).
SidebarMenu / SidebarMenuItem
<ul> / <li> — real list semantics, so screen readers announce row counts.
| SidebarMenuItem prop | Type | Default | Description |
|---|---|---|---|
collapsible | boolean | false | Make the row's SidebarMenuSub expandable via SidebarMenuToggle |
open / defaultOpen / onOpenChange | — | defaultOpen: true | Expanded state of the sub list |
SidebarMenuButton
The row control.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "ghost" | "pill" | "accent" | "default" | Row treatment; accent adds the inline-start indicator bar |
size | "sm" | "md" | "lg" | "md" | Row density (44px floor below md regardless) |
active | boolean | false | Current destination — paints the active state and sets aria-current="page" |
intent | "none" | "error" | "none" | error is the destructive row (sign out, delete) |
description | ReactNode | — | Second muted line; keep children to icon + label when set |
tooltip | string | — | Real tooltip beside the icon rail while collapsed (hover + focus, instant) |
render | ReactElement | — | Swap the <button> for <a> / router Link; refs merge |
Content convention: an icon element, then a <span> label, then trailing
slots. The label truncates; the button auto-reserves end padding when a
SidebarMenuAction shares the row.
SidebarMenuToggle
The disclosure chevron for a collapsible item — a separate control from the
row button, with aria-expanded/aria-controls, hidden in the icon rail.
SidebarMenuAction
Floating row action (kebab). Must be a sibling of SidebarMenuButton
(never nested — buttons can't contain buttons). showOnHover fades it in on
row hover, reveals on any focus in the row and while its own menu is open, and
keeps it always visible on touch viewports — it never leaves the DOM.
SidebarMenuBadge
Trailing signal inside the row.
| Prop | Type | Default | Description |
|---|---|---|---|
tone | "neutral" | "primary" | "success" | "warning" | "error" | "info" | "neutral" | Intent tokens |
max | number | — | Cap numeric children — max={99} renders "99+" |
dot | boolean | false | Small dot instead of the pill; children become sr-only text |
Badges are informational — anything interactive belongs in
SidebarMenuAction. In the icon rail the badge renders as a corner dot on the
icon (with a --surface-fill halo) instead of disappearing.
SidebarMenuSub / SidebarMenuSubItem / SidebarMenuSubButton
Nested link list with a start guideline. Inside a collapsible item the list
is the animated disclosure panel. Keep nesting to this one sub level.
SidebarSkeleton
Standalone loading placeholder — no provider required. size drives width,
row height and bar text size; label-bar widths are index-derived so server
and client agree. groups, footerItems, showBrand mirror the real
anatomy; label (default "Loading") is the single sr-only announcement — load
the whole nav at once, never row by row.
useSidebar()
const {
open, setOpen, openMobile, setOpenMobile, toggle,
isMobile, state, side, size, dir, label, widthPx, setWidthPx,
} = useSidebar();Throws outside a SidebarProvider. toggle drives custom collapse buttons;
setWidthPx(null) returns a resizable sidebar to its size preset.
Keyboard
| Key | Where | Result |
|---|---|---|
Tab / Shift+Tab | rail & drawer | Moves through rows in order — nav is plain links/buttons, deliberately not a menu/tree widget |
Enter / Space | any row | Activates the link/button |
Cmd/Ctrl + B | global | Toggles the sidebar. The first mounted provider claims it (dual-rail stays unambiguous); ignored while typing in inputs/editors |
Enter / Space | group label, SidebarMenuToggle | Toggles the disclosure (aria-expanded) |
← → / Home End | focused resizable rail | Steps / clamps the width |
Esc, swipe to edge, backdrop | mobile drawer | Dismisses; focus returns to the trigger |
RTL
Pass dir="rtl" on the provider: docking flips, chevrons, badges and the
trigger icon mirror, and the mobile drawer swipes toward its logical edge.
Accessibility
- The desktop rail is
<aside role="navigation">named bylabel— name the destination set, and give multiple sidebars distinct labels. - The mobile drawer is a portalled modal dialog on the Drawer spine: focus
trapped and restored,
Esccloses, scroll locked, backdrop dismisses, optionaldescriptionannounced. - Current destination carries
aria-current="page"(fromactive) — state is announced, never colour-only. - Disclosure controls (group labels,
SidebarMenuToggle) are real buttons witharia-expanded+aria-controls; collapsed panels and the off-canvas rail areinert, so nothing invisible is tabbable. - Collapsed-rail tooltips open on keyboard focus, not only hover; row actions reveal on focus and stay present on touch.
- Touch targets: rows, trigger, actions and search all hold the 44px floor
below
md; the drawer pads safe-area insets. - Motion (width transition, drawer slide, chevrons) honours
prefers-reduced-motion. - AT matrix: VoiceOver smoke pending (manual, run it before calling this
cycle done); NVDA and JAWS untested (no Windows host) — status stays
betauntil real AT passes are recorded.
Migration (from the previous Sidebar)
hideWhenCollapsedis removed — it never functioned.rounded="full"shells now render 24px (panel cap) instead of 16px.intent="inverse"repaints via the surface-ink contract (was a raw foreground/background swap that flipped in dark mode).- The mobile drawer is portalled and unmounts when closed; retarget CSS from
descendant selectors to
[data-slot="sidebar"][data-mobile]. SidebarMenuButton tooltiprenders a real tooltip instead of atitleattribute.