DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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.

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"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: 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"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):

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

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.

// 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 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 below md 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 touch viewports — 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.

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

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, 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 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.
  • 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 beta until real AT passes are recorded.

Migration (from the previous Sidebar)

  • hideWhenCollapsed is 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 tooltip renders a real tooltip instead of a title attribute.
ShortcutSkeleton

On this page

PlaygroundInstallationUsageExamplesDefaultShell variantsRow variantsInverseCollapsed railRail flyoutCollapsible submenuStatic submenuBadges and countsRich rowsWorkspace switcherAccount footerSettings shapePlan widgetsDual railResizableMobile drawerSkeletonWhen to useAPI ReferenceSidebarProviderSidebarSidebarTriggerSidebarRailSidebarInsetSidebarHeader / SidebarFooter / SidebarContent / SidebarSeparatorSidebarBrandSidebarSearchSidebarGroup / SidebarGroupLabel / SidebarGroupAction / SidebarGroupContentSidebarMenu / SidebarMenuItemSidebarMenuButtonSidebarMenuToggleSidebarMenuActionSidebarMenuBadgeSidebarMenuSub / SidebarMenuSubItem / SidebarMenuSubButtonSidebarSkeletonuseSidebar()KeyboardRTLAccessibilityMigration (from the previous Sidebar)