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.
pnpm add @tessinaui/uiWhen to use
- App shells with a handful of destinations or hierarchy worth grouping: dashboards, consoles, editors, mail.
- Persistent library or tree navigation: projects, folders, channels, playlists, where people switch between items often.
- One level of sub-navigation. Keep the sidebar to parent plus one sub
level;
SidebarMenuToggleandSidebarMenuSubhandle the disclosure. - Space that flexes. Collapse to an icon rail or off-canvas, or let people drag-resize, when content needs the room.
- Phones too. Below the mobile breakpoint the same sidebar becomes a modal drawer, so one composition covers both.
When not to use
- A temporary panel that is not the app's primary navigation. Use Drawer.
- Filtering the current view. A sidebar moves between destinations; filters belong to Tabs, chips or a filter panel.
- Deep nesting. More than parent plus one sub level: give deeper data an intermediate list view instead.
- A simple product with few top-level sections. A top NavigationMenu is lighter.
- Phone-first apps with a few peer sections. Use BottomNav.
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.
On color: the inverse rail
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
The icon rail hides SidebarMenuSub and its toggle, so a row whose children
must stay one click away composes a flyout: while useSidebar().state is
"collapsed", render the row button as a DropdownMenuTrigger. Here Projects
opens its destinations from the 56px rail (size="sm"); the team row, which
composes none, reaches its sub-list by expanding the sidebar.
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, tap the
widen/narrow steppers on the rail, or focus it and use ←/→ (Home/End for the
bounds). The steppers are the single-pointer alternative to dragging (WCAG
2.5.7) and, at 44px on touch, the strip's full-size equivalent target: always
visible on touch, revealed on hover with a mouse. 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.
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" | Width preset — 244/288/320px expanded, a 56/64/72px icon rail |
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" | inherited | Text and layout direction. Unset, read off the provider's position in the page |
strings | Partial<SidebarStrings> | English, Arabic under dir="rtl" | Override the built-in names: label (landmark and drawer), toggle (the trigger, and a rail that only toggles), resize (a resizing rail), widen / narrow (the rail's steppers), search (SidebarSearch's placeholder) and toggleSubmenu (SidebarMenuToggle). label and placeholder props win |
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", Arabic under dir="rtl" | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
tapEdge | "none" | "inline-start" | "inline-end" | "none" | Declares that the trigger sits flush against a layout edge, so its phone tap extension grows inward instead of across that edge. Logical, so it follows dir. Same prop and semantics as on IconButton |
The trigger is the one sidebar control you place yourself, so it is the one that
can end up flush against a full-bleed edge — a hamburger at the start of an app
header is the usual case. Its 44px phone tap target is an invisible
pseudo-element, and one of those still counts toward its ancestors' scrollable
overflow, so at an edge it adds a few pixels of horizontal scroll: invisible,
but real on a phone. Pass tapEdge and the same 44px target is anchored at that
edge and grown inward instead.
<header className="flex items-center gap-2 px-0">
<SidebarTrigger tapEdge="inline-start" />
<h1>Inbox</h1>
</header>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 — with a pointer-only "Widen
sidebar" / "Narrow sidebar" stepper pair beside it while expanded (16px per
tap, aria-disabled at the bounds). Expanded or icon-collapsed, the 8px strip
starts at the panel's border and runs over the inset's gutter, never over the
panel's own edge controls or scrollbar; with a mouse the steppers take no
clicks until the strip reveals them. 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 on a touch pointer 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 a touch pointer, a tablet included — 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. The icon
rail does not render it; compose a flyout where its links must stay reachable
there (see Rail flyout).
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", Arabic under dir="rtl") 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, badges and the trigger icon
mirror, collapsed-rail tooltips open toward the content, and the mobile drawer
swipes toward its logical edge. Leave dir unset and the provider reads the
direction it inherits from the page.
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. - A
resizablerail is a focusable window splitter:role="separator",aria-orientation="vertical", the width inaria-valuenow/aria-valuetext("Collapsed" while collapsed), bounds inaria-valuemin/aria-valuemax, andaria-controlspointing at the panel. Screen readers announce each width change; Enter / Space toggle. The plain rail stays a pointer-only button. - 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 on a touch pointer, a tablet included; 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.dirno longer defaults to"ltr": unset, the provider follows the page's direction. Passdir="ltr"to keep an LTR sidebar on an RTL page.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.