DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
BreadcrumbTabsPaginationStepperMenu ItemToolbarMenubarNavigation MenuAccordionCollapsibleCommandBottom NavTop Header MobileTop Header DesktopSidebar
AI agentsContributing
  1. Components
  2. Sidebar

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/ui

When 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; SidebarMenuToggle and SidebarMenuSub handle 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  SidebarProvider,
  Sidebar,
  SidebarHeader,
  SidebarContent,
  SidebarFooter,
  SidebarBrand,
  SidebarSearch,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarGroupContent,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
  SidebarMenuBadge,
  SidebarTrigger,
  SidebarInset,
} from "@tessinaui/ui";
example.tsx
<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.

PropTypeDefaultDescription
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"inheritedText and layout direction. Unset, read off the provider's position in the page
stringsPartial<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: trueDesktop open state, controlled or uncontrolled
openMobile / onOpenMobileChange——Mobile drawer state, controlled or uncontrolled
mode"desktop" | "mobile"autoForce a render mode; auto switches at 768px via matchMedia
keyboardShortcutbooleantrueCmd/Ctrl+B toggling — see Keyboard below
labelstring"Sidebar", Arabic under dir="rtl"Accessible name for the landmark and the mobile drawer. Name the destination set ("Mail folders"), skip the word "navigation"
persistKeystring—Persist open state and resized width to localStorage under this key
resizableboolean | { min?, max? }falseLet 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):

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

example.tsx
// 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.

PropTypeDefaultDescription
variant / side / intent—from providerRarely-needed per-instance overrides
labelstringprovider labelOverride the landmark / drawer name
descriptionstring—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.

example.tsx
// 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.

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

example.tsx
<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 propTypeDefaultDescription
collapsiblebooleanfalseMake the row's SidebarMenuSub expandable via SidebarMenuToggle
open / defaultOpen / onOpenChange—defaultOpen: trueExpanded state of the sub list

SidebarMenuButton

The row control.

PropTypeDefaultDescription
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)
activebooleanfalseCurrent destination — paints the active state and sets aria-current="page"
intent"none" | "error""none"error is the destructive row (sign out, delete)
descriptionReactNode—Second muted line; keep children to icon + label when set
tooltipstring—Real tooltip beside the icon rail while collapsed (hover + focus, instant)
renderReactElement—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.

PropTypeDefaultDescription
tone"neutral" | "primary" | "success" | "warning" | "error" | "info""neutral"Intent tokens
maxnumber—Cap numeric children — max={99} renders "99+"
dotbooleanfalseSmall 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()

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

KeyWhereResult
Tab / Shift+Tabrail & drawerMoves through rows in order — nav is plain links/buttons, deliberately not a menu/tree widget
Enter / Spaceany rowActivates the link/button
Cmd/Ctrl + BglobalToggles the sidebar. The first mounted provider claims it (dual-rail stays unambiguous); ignored while typing in inputs/editors
Enter / Spacegroup label, SidebarMenuToggleToggles the disclosure (aria-expanded)
← → / Home Endfocused resizable railSteps / clamps the width
Esc, swipe to edge, backdropmobile drawerDismisses; 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 by label — 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, Esc closes, scroll locked, backdrop dismisses, optional description announced.
  • Current destination carries aria-current="page" (from active) — state is announced, never colour-only.
  • Disclosure controls (group labels, SidebarMenuToggle) are real buttons with aria-expanded + aria-controls; collapsed panels and the off-canvas rail are inert, so nothing invisible is tabbable.
  • A resizable rail is a focusable window splitter: role="separator", aria-orientation="vertical", the width in aria-valuenow / aria-valuetext ("Collapsed" while collapsed), bounds in aria-valuemin / aria-valuemax, and aria-controls pointing 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 beta until real AT passes are recorded.

Migration (from the previous Sidebar)

  • hideWhenCollapsed is removed — it never functioned.
  • dir no longer defaults to "ltr": unset, the provider follows the page's direction. Pass dir="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 tooltip renders a real tooltip instead of a title attribute.
Top Header DesktopPopover

On this page

When to useWhen not to useInstallationUsageExamplesDefaultShell variantsRow variantsOn color: the inverse railCollapsed railRail flyoutCollapsible submenuStatic submenuBadges and countsRich rowsWorkspace switcherAccount footerSettings shapePlan widgetsDual railResizableMobile drawerSkeletonAPI ReferenceSidebarProviderSidebarSidebarTriggerSidebarRailSidebarInsetSidebarHeader / SidebarFooter / SidebarContent / SidebarSeparatorSidebarBrandSidebarSearchSidebarGroup / SidebarGroupLabel / SidebarGroupAction / SidebarGroupContentSidebarMenu / SidebarMenuItemSidebarMenuButtonSidebarMenuToggleSidebarMenuActionSidebarMenuBadgeSidebarMenuSub / SidebarMenuSubItem / SidebarMenuSubButtonSidebarSkeletonuseSidebar()KeyboardRTLAccessibilityMigration (from the previous Sidebar)