Skip to content
Component installs need the registry setup
VegaStack Design

Sidebar

A composable, collapsible navigation rail — provider, panel, groups, menu rows with actions, badges and submenus, plus a rail and a trigger.

Status
stable
Since
0.1.0
Accessibility pattern
composed shell region, main landmark on the inset

Last updated

Playground
Page content sits in SidebarInset.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Sidebar before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify sidebar

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/sidebar

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/input, @vegastack/page-layout, @vegastack/separator, @vegastack/sheet, @vegastack/skeleton, @vegastack/tooltip, @vegastack/use-mobile, @vegastack/use-platform.

Usage

Wrap the application in SidebarProvider, put the navigation in Sidebar, and put the page in SidebarInset. SidebarTrigger toggles the panel from anywhere inside the provider.

app/layout.tsx
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <AppSidebar />
      <main>
        <SidebarTrigger />
        {children}
      </main>
    </SidebarProvider>
  );
}
components/app-sidebar.tsx
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
} from "@/components/ui/sidebar";

export function AppSidebar() {
  return (
    <Sidebar>
      <SidebarHeader />
      <SidebarContent>
        <SidebarGroup />
        <SidebarGroup />
      </SidebarContent>
      <SidebarFooter />
    </Sidebar>
  );
}

The desktop panel is position: fixed and appears at the md breakpoint; below it the same children mount inside a Sheet. Every demo on this page therefore renders in a frame that carries contain: paint, which makes the frame the fixed-positioning containing block — an application shell has the viewport for that, a documentation page does not.

Playground
Page content sits in SidebarInset.

Anatomy

Sidebar — data-slot="sidebar" | "sidebar-container" | "sidebar-gap" | "sidebar-inner"
SidebarContent — data-slot="sidebar-content"
SidebarFooter — data-slot="sidebar-footer"
SidebarGroup — data-slot="sidebar-group"
SidebarGroupAction
SidebarGroupContent — data-slot="sidebar-group-content"
SidebarGroupLabel
SidebarHeader — data-slot="sidebar-header"
SidebarInput — data-slot="sidebar-input"
SidebarInset — data-slot="sidebar-inset"
SidebarMenu — data-slot="sidebar-menu"
SidebarMenuAction
SidebarMenuBadge — data-slot="sidebar-menu-badge"
SidebarMenuButton — data-slot="sidebar-menu-badge-rail"
SidebarMenuItem — data-slot="sidebar-menu-item"
SidebarMenuSkeleton — data-slot="sidebar-menu-skeleton"
SidebarMenuSub — data-slot="sidebar-menu-sub"
SidebarMenuSubButton
SidebarMenuSubItem — data-slot="sidebar-menu-sub-item"
SidebarProvider — data-slot="sidebar-wrapper"
SidebarRail — data-slot="sidebar-rail"
SidebarSeparator — data-slot="sidebar-separator"
SidebarStateScript — data-slot="sidebar-state-script"
SidebarTrigger — data-slot="sidebar-trigger"

Examples

Composition

SidebarProvider owns the state; Sidebar and SidebarInset are its two children. Everything else nests inside Sidebar.

SidebarProvider
├── Sidebar
│   ├── SidebarHeader
│   ├── SidebarContent
│   │   ├── SidebarGroup
│   │   │   ├── SidebarGroupLabel
│   │   │   ├── SidebarGroupAction
│   │   │   ├── SidebarGroupContent
│   │   │   └── SidebarMenu
│   │   │       ├── SidebarMenuItem
│   │   │       │   ├── SidebarMenuButton
│   │   │       │   ├── SidebarMenuAction
│   │   │       │   └── SidebarMenuBadge
│   │   │       └── SidebarMenuItem
│   │   │           ├── SidebarMenuButton
│   │   │           └── SidebarMenuSub
│   │   │               ├── SidebarMenuSubItem
│   │   │               └── SidebarMenuSubItem
│   │   └── SidebarGroup
│   │       └── SidebarMenu
│   │           ├── SidebarMenuItem
│   │           └── SidebarMenuItem
│   ├── SidebarFooter
│   └── SidebarRail
├── SidebarInset
└── SidebarTrigger
SidebarInset

Structure

  • SidebarProvider — holds the collapsible state and provides the sidebar context to every child.
  • Sidebar — the collapsible panel itself.
  • SidebarHeader — the top of the panel: branding, titles, workspace switchers.
  • SidebarFooter — the bottom of the panel: user menus, settings, actions.
  • SidebarContent — the scrollable region between the two.
  • SidebarGroup — one labelled section, with an optional action and a content area.
  • SidebarMenu / SidebarMenuItem — a real ul/li list for links, badges, actions and submenus.
  • SidebarRail — the thin strip on the panel's outer edge that toggles it.
  • SidebarInset — the main landmark that wraps the page beside the panel.
  • SidebarTrigger — the button that toggles the panel.
SidebarTrigger
SidebarInset wraps the main content.

SidebarProvider

Always wrap the application in SidebarProvider. It owns open, writes the sidebar_state cookie on every uncontrolled toggle, and binds ⌘/Ctrl + B (see Keyboard shortcut below to retune or turn it off).

For a single sidebar, the SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON and SIDEBAR_WIDTH_MOBILE constants in sidebar.tsx set the widths. For more than one, set --sidebar-width and --sidebar-width-icon in the provider's style — those are the two variables the provider publishes on its wrapper, so a second sidebar can carry its own. The mobile sheet is not overridable this way: it sets --sidebar-width to SIDEBAR_WIDTH_MOBILE on its own content, which wins over anything inherited.

<SidebarProvider
  style={
    {
      "--sidebar-width": "20rem",
      "--sidebar-width-icon": "3rem",
    } as React.CSSProperties
  }
>
  <Sidebar />
</SidebarProvider>
⌘/Ctrl + B toggles the sidebar

side picks the edge, variant picks the surface treatment, and collapsible picks what collapsing means: offcanvas slides the panel out, icon shrinks it to a rail of icons, and none renders a plain, always-visible column. Each value lands on the panel as data-side, data-variant and data-collapsible, which is what the group-data-* recipes below read.

With variant="inset", wrap the page in SidebarInset — the provider paints the sidebar surface behind an inset page, and the main content is the floating card on top of it.

<SidebarProvider>
  <Sidebar variant="inset" />
  <SidebarInset>
    <main>{children}</main>
  </SidebarInset>
</SidebarProvider>
Inset content

useSidebar

useSidebar reads and drives the panel from anywhere inside the provider. It throws outside one, so a custom trigger has to live in the tree the provider wraps.

PropertyTypeDescription
stateexpanded or collapsedThe current state of the sidebar.
openbooleanWhether the sidebar is open.
setOpen(open: boolean) => voidSets the open state of the sidebar.
openMobilebooleanWhether the sidebar is open on mobile.
setOpenMobile(open: boolean) => voidSets the open state of the sidebar on mobile.
isMobilebooleanWhether the sidebar is on mobile.
toggleSidebar() => voidToggles the sidebar. Desktop and mobile.

state: expanded · open: true · isMobile: false

SidebarHeader

SidebarHeader is the sticky top of the panel — a workspace switcher, a logo, or a SidebarInput search field.

<Sidebar>
  <SidebarHeader>
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger render={<SidebarMenuButton />}>
            Select Workspace
            <ChevronDown className="ms-auto" />
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem>Acme Inc</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarHeader>
</Sidebar>

SidebarFooter

SidebarFooter is the sticky bottom of the panel — the account row, settings, a sign-out. The standard account menu (the one the Regent app ships) is in app-shell-01: the avatar row opens a menu with Profile and Settings, a Theme submenu (Light · Dark · System), Keyboard shortcuts and Sign out, every item with an icon. Leading icons in menu buttons are muted until their row is hovered, focused or active — the component does this, so add no colour classes (see Icons).

<Sidebar>
  <SidebarFooter>
    <SidebarMenu>
      <SidebarMenuItem>
        <SidebarMenuButton>
          <User2 /> Username
        </SidebarMenuButton>
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarFooter>
</Sidebar>

SidebarContent

SidebarContent is the scrollable region between the header and the footer, and it is where SidebarGroup belongs.

<Sidebar>
  <SidebarContent>
    <SidebarGroup />
    <SidebarGroup />
  </SidebarContent>
</Sidebar>
Acme Inc
Workspace
Footer stays put

SidebarGroup

A group is one section of the panel: a SidebarGroupLabel, an optional SidebarGroupAction, and a SidebarGroupContent.

<SidebarGroup>
  <SidebarGroupLabel>Application</SidebarGroupLabel>
  <SidebarGroupAction>
    <Plus /> <span className="sr-only">Add Project</span>
  </SidebarGroupAction>
  <SidebarGroupContent></SidebarGroupContent>
</SidebarGroup>

To make a group collapsible, wrap it in a Collapsible and make the label the trigger with render.

<Collapsible defaultOpen className="group/collapsible">
  <SidebarGroup>
    <SidebarGroupLabel render={<CollapsibleTrigger />}>
      Help
      <ChevronDown className="ms-auto group-data-open/collapsible:rotate-180" />
    </SidebarGroupLabel>
    <CollapsibleContent>
      <SidebarGroupContent />
    </CollapsibleContent>
  </SidebarGroup>
</Collapsible>
Application

A group has an action or a collapse chevron, never both — they would share the label's end. showOnHover on SidebarGroupAction (API-33, the same shape as SidebarMenuAction's) hides the action from md up on a fine pointer until the group is hovered or holds focus; on touch it always shows. A group whose list should always be visible (Spaces) drops the Collapsible and keeps the action. In a SidebarMenuButton row, draw a space with IconGlyph size="2xs": its 16px tile lines up with the nav icons around it.

Work
Spaces

SidebarMenu

SidebarMenu is a ul, and every SidebarMenuItem is an li, so a screen reader announces the navigation as a list with a length.

<SidebarMenu>
  {projects.map((project) => (
    <SidebarMenuItem key={project.name}>
      <SidebarMenuButton render={<a href={project.url} />}>
        <project.icon />
        <span>{project.name}</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  ))}
</SidebarMenu>

SidebarMenuButton

SidebarMenuButton is the row itself. It renders a button by default; pass render to make it an anchor or a framework Link. isActive marks the current route, size picks between a compact row, the default and a two-line row, and variant="outline" gives it a hairline. On a link, isActive also sets aria-current="page"; a button never gets it.

<SidebarMenuButton render={<a href="#" />} isActive>
  Home
</SidebarMenuButton>
isActive and render
size
variant

A SidebarMenuButton used as a menu trigger (the user menu in the footer) styles its own open state: while its menu is open (aria-expanded / data-popup-open) it keeps the sidebar-accent fill and ink, so the trigger needs no className.

SidebarMenuAction

SidebarMenuAction is a second control on the same row — an overflow menu, an add button. It sits inside the SidebarMenuItem, after the button, and showOnHover keeps it out of the way until the row is hovered or something inside it takes focus.

<SidebarMenuItem>
  <SidebarMenuButton render={<a href="#" />}>
    <Home />
    <span>Home</span>
  </SidebarMenuButton>
  <SidebarMenuAction>
    <Plus /> <span className="sr-only">Add Project</span>
  </SidebarMenuAction>
</SidebarMenuItem>
Projects

SidebarMenuSub

A submenu is a nested list under a row, hidden entirely in icon mode. SidebarMenuSubButton takes the same render and isActive props as the row above it, plus a size of sm or md.

<SidebarMenuItem>
  <SidebarMenuButton />
  <SidebarMenuSub>
    <SidebarMenuSubItem>
      <SidebarMenuSubButton />
    </SidebarMenuSubItem>
  </SidebarMenuSub>
</SidebarMenuItem>
Documentation

SidebarMenuBadge

A badge is a count pinned to the end of a row. It is pointer-events: none, so it never steals the row's click, and it disappears in icon mode. It is also aria-hidden: the count a screen reader hears is part of the row's own name, so give the button a badgeLabel. The simplest way is the button's badge prop, which renders this part for you (see Counts below).

<SidebarMenuItem>
  <SidebarMenuButton badgeLabel="24 unread" />
  <SidebarMenuBadge>24</SidebarMenuBadge>
</SidebarMenuItem>
Counts

SidebarMenuSkeleton

SidebarMenuSkeleton stands in for a row while the navigation loads. showIcon adds the leading square, and index picks the text width from a fixed cycle (70%, 55%, 85%, 60%, 75%), so a list of rows looks varied and the server renders exactly what the client hydrates (LAY-16).

<SidebarMenu>
  {Array.from({ length: 5 }).map((_, index) => (
    <SidebarMenuItem key={index}>
      <SidebarMenuSkeleton index={index} />
    </SidebarMenuItem>
  ))}
</SidebarMenu>
Loading

SidebarTrigger

SidebarTrigger is the toggle. It is an ordinary Button wired to toggleSidebar, so any button can do the same job through useSidebar.

import { useSidebar } from "@/components/ui/sidebar";

export function CustomTrigger() {
  const { toggleSidebar } = useSidebar();

  return <button onClick={toggleSidebar}>Toggle sidebar</button>;
}

Its accessible name is "Toggle sidebar"; pass triggerLabel to localise it.

SidebarTrigger
The trigger calls toggleSidebar() from useSidebar; any button can do the same.

SidebarRail

SidebarRail is the thin strip along the panel's outer edge that toggles it on click. It is deliberately outside the tab order — SidebarTrigger is the keyboard affordance and the rail is the pointer one — so a sidebar that ships a rail still ships a trigger. Its aria-label and title read "Toggle sidebar", and triggerLabel overrides both.

<Sidebar>
  <SidebarHeader />
  <SidebarContent>
    <SidebarGroup />
  </SidebarContent>
  <SidebarFooter />
  <SidebarRail />
</Sidebar>
The rail is the thin strip on the panel edge

Controlled Sidebar

Pass open and onOpenChange to drive the panel from your own state. A controlled provider writes no sidebar_state cookie of its own — persistence becomes the host's decision, and useSidebarCookieOpen (see Static shells below) is the ready-made one.

export function AppSidebar() {
  const [open, setOpen] = React.useState(false);

  return (
    <SidebarProvider open={open} onOpenChange={setOpen}>
      <Sidebar />
    </SidebarProvider>
  );
}

open: true

Theming

Eight CSS variables theme the whole panel. Set them anywhere above the provider — the root, a wrapper, or the provider's own style — and every part below inherits them.

:root {
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.205 0 0);
}
Inverted palette
Workspace

Styling

The panel publishes its state as data attributes, so a consumer styles against them rather than against a prop. group-data-[collapsible=icon]: hides what icon mode cannot show:

<Sidebar collapsible="icon">
  <SidebarContent>
    <SidebarGroup className="group-data-[collapsible=icon]:hidden" />
  </SidebarContent>
</Sidebar>

…and peer-data-active/menu-button: reaches an action from the state of the row beside it. Prefix it with md: when the action also carries showOnHover: that recipe's own md:opacity-0 compiles to the same specificity as the override, so without the media prefix the hidden state wins on source order.

<SidebarMenuItem>
  <SidebarMenuButton />
  <SidebarMenuAction
    showOnHover
    className="md:peer-data-active/menu-button:opacity-100"
  />
</SidebarMenuItem>
Projects

RTL

Wrap the subtree in DirectionProvider direction="rtl" and pass dir="rtl" to Sidebar — the dir travels to the mobile SheetContent, which portals out of that subtree and would otherwise render left to right. Every inset, border and margin in the panel is already logical, so side="right" anchors it to the reading start and SidebarTrigger's icon flips with it.

<Sidebar dir="rtl" side="right">
  {/* ... */}
</Sidebar>
لوحة التحكم

Counts

Pass badge to SidebarMenuButton and it renders the visual SidebarMenuBadge beside the row, while badgeLabel joins the row's accessible name: the link below is announced as "Inbox 12 unread". A string or number badge names itself when you leave badgeLabel out. The label truncates before it reaches the count, and a count of three characters or more gets a wider reserve. When the panel collapses to icons, the tooltip carries the count too.

<SidebarMenuButton
  render={<a href="/inbox" />}
  isActive
  tooltip="Inbox"
  badge="12"
  badgeLabel="12 unread"
>
  <Inbox />
  <span>Inbox</span>
</SidebarMenuButton>

On a collapsed icon rail the count stays in sight at the icon's top-end corner: the count itself in a small pill up to two characters, a dot beyond. It is decorative — the name already carries the count.

Count with a hover action

A row can carry both a count and a quick action in the same spot. Add a SidebarMenuAction showOnHover beside a badge and the two share one slot:

  • Rest — the count shows; the action is hidden.
  • Hover, focus or open menu — while the row is hovered, holds keyboard focus, or the action's menu is open, the count hides and the action shows in exactly its place. Leaving the row brings the count back. There is no layout shift and no motion.
  • Touch — below md, or on a coarse pointer at any width, there is no hover, so the action stays visible and the count sits just before it, side by side; the label truncates short of both.

This works for any item with a badge and a showOnHover action. Name the action with aria-label. Hover the Tasks row in the preview to see the swap; narrow the window to see the touch layout.

<SidebarMenuItem>
  <SidebarMenuButton render={<a href="/tasks" />} badge="8" badgeLabel="8 open">
    <ListTodo />
    <span>Tasks</span>
  </SidebarMenuButton>
  <SidebarMenuAction showOnHover aria-label="New task" onClick={openNewTask}>
    <Plus />
  </SidebarMenuAction>
</SidebarMenuItem>

Action row with shortcut

A row that opens a palette shows its shortcut with Kbd. Mark the keys aria-hidden and put the chord on the button with aria-keyshortcuts, so the name stays "Search". Build the modifier with formatShortcutKey from use-platform, so it reads ⌘ on a Mac and Ctrl everywhere else. A row with a secondary action keeps it in SidebarMenuAction, a separate control.

function SearchRow() {
  const { os } = usePlatform();
  return (
    <SidebarMenuButton variant="outline" aria-keyshortcuts="Meta+K Control+K">
      <Search />
      <span>Search</span>
      <KbdGroup aria-hidden="true" className="ms-auto">
        <Kbd>{formatShortcutKey("mod", os)}</Kbd>
        <Kbd>K</Kbd>
      </KbdGroup>
    </SidebarMenuButton>
  );
}

Collapsible group

A menu row can open a submenu. Render the Collapsible as the SidebarMenuItem, so the menu's ul keeps only li children, render the row as a CollapsibleTrigger, which gives it aria-expanded, and put the SidebarMenuSub in CollapsibleContent. Mark the current sub-page with isActive and aria-current="page" on its link.

<Collapsible
  defaultOpen
  className="group/collapsible"
  render={<SidebarMenuItem />}
>
  <SidebarMenuButton render={<CollapsibleTrigger />}>
    <SquareTerminal />
    <span>Playground</span>
    <ChevronRight className="ms-auto group-data-open/collapsible:rotate-90" />
  </SidebarMenuButton>
  <CollapsibleContent>
    <SidebarMenuSub>{/* SidebarMenuSubItem rows */}</SidebarMenuSub>
  </CollapsibleContent>
</Collapsible>
Platform

Unavailable item

An item the user cannot open yet stays in the list with aria-disabled="true". The row keeps its pointer events (FRM-4), so a Tooltip can say why, and aria-describedby points at a visually hidden copy of the same reason for a screen reader. A short badge marks the state at a glance; badgeLabel gives it words, so the row is named "Agents Coming soon" rather than "Agents TBD".

<SidebarMenuItem>
  <Tooltip>
    <TooltipTrigger
      render={
        <SidebarMenuButton
          aria-disabled="true"
          aria-describedby="agents-reason"
          badge="TBD"
          badgeLabel="Coming soon"
        />
      }
    >
      <Bot />
      <span>Agents</span>
    </TooltipTrigger>
    <TooltipContent side="right">Available on the Team plan</TooltipContent>
  </Tooltip>
  <span id="agents-reason" className="sr-only">
    Available on the Team plan
  </span>
</SidebarMenuItem>
  • Home
  • Available on the Team plan

Keyboard shortcut

⌘/Ctrl + B toggles the sidebar from anywhere on the page, except where the user is typing: the provider ignores the chord inside an input, a textarea, a select or a contenteditable element (isEditableTarget from use-platform), and ignores an event another handler has already preventDefaulted. keyboardShortcut changes the key, and false turns the binding off — for an editor that owns ⌘ + B for bold, say.

<SidebarProvider keyboardShortcut="j">{/* ⌘/Ctrl + J */}</SidebarProvider>
<SidebarProvider keyboardShortcut={false}>{/* no chord */}</SidebarProvider>

Press ⌘/Ctrl + B. Typing it in the field below does nothing.

Static shells

A shell that is prerendered or cached cannot read the sidebar_state cookie on the server, so it renders the panel expanded and a collapsed sidebar would jump on hydration. Put SidebarStateScript in the root layout's <head>: before first paint it reads the cookie, marks <html> with data-sidebar-state="collapsed", and gives the desktop panel the data-state and data-collapsible its own recipes already read, so it paints collapsed. Pass it the same collapsible as your Sidebar, and the request's nonce under a nonce-based Content Security Policy. The provider starts from that mark, so hydration agrees with the screen and React warns about nothing.

A default that depends on the window — say, collapsed on a tablet until someone opens it — goes in the same script: collapsedWhen (a media query) starts a panel nobody has opened or closed yet collapsed in a window it matches, and writes that start to the cookie. It is decided before the first paint, never by collapsing the panel after hydration, which would slide it shut and move the whole page. A person's own choice (the cookie) always wins. With collapsible="icon" it defaults to the tablet span — RAIL_COLLAPSED_QUERY from page-layout, 768–1023px — where the rail rests as icons; pass collapsedWhen={null} to turn it off. AppShell folds an open rail when the window narrows into that span and reopens it on the way out.

For a controlled provider, useSidebarCookieOpen returns [open, setOpen]: it starts from the same mark, reads the cookie after hydration when there is no mark, and its setter writes the cookie (SameSite=Lax). The controlled provider itself never writes one.

app/layout.tsx
import { SidebarStateScript } from "@/components/ui/sidebar";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        {/* Starts as icons on a tablet (RAIL_COLLAPSED_QUERY) unless someone chose. */}
        <SidebarStateScript collapsible="icon" />
      </head>
      <body>{children}</body>
    </html>
  );
}
components/app-frame.tsx
"use client";

import { SidebarProvider, useSidebarCookieOpen } from "@/components/ui/sidebar";

export function AppFrame({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useSidebarCookieOpen();
  return (
    <SidebarProvider open={open} onOpenChange={setOpen}>
      {children}
    </SidebarProvider>
  );
}

suppressHydrationWarning on <html> covers the data-sidebar-state attribute the script adds there. The script is inline, so a strict Content Security Policy needs a nonce or hash for it.

Collapse it, then reload the page
useSidebarCookieOpen keeps the state in the sidebar_state cookie, which SidebarStateScript reads before first paint.

API Reference

PropTypeDefaultDescription
defaultOpenboolean—
keyboardShortcutstring | false—
onOpenChange((open: boolean) => void)—
openboolean—
PropTypeDefaultDescription
collapsible"icon" | "none" | "offcanvas"—
side"left" | "right"—
variant"floating" | "inset" | "sidebar"—
PropTypeDefaultDescription
badgeReact.ReactNode—
badgeLabelstring—
isActiveboolean—
size"default" | "lg" | "sm" | null—
tooltipstring | (TooltipPopupProps & Pick<TooltipPositionerProps, "align" | "alignOffset" | "side" | "sideOffset"> & Pick<TooltipPortalProps, "container">)—
variant"default" | "outline" | null—
PropTypeDefaultDescription
showOnHoverboolean—
PropTypeDefaultDescription
isActiveboolean—
size"md" | "sm"—
PropTypeDefaultDescription
indexnumber—
showIconboolean—
PropTypeDefaultDescription
loadingboolean—Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its box at opacity: 0, so the button's width does not move and its accessible name survives (API-5, A11Y-12).
size"default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | null—
triggerLabelstring—
variant"default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null—
PropTypeDefaultDescription
triggerLabelstring—
PropTypeDefaultDescription
collapsedWhenstring | nullRAIL_COLLAPSED_QUERY with `collapsible="icon"`, else noneA media query: with no cookie yet, a window it matches starts collapsed (and the cookie records it); null never.
collapsible"icon" | "none" | "offcanvas"—
cookieNamestring—
noncestring—

useSidebarCookieOpen(cookieName?: string) returns [open: boolean, setOpen: (open: boolean) => void]; cookieName defaults to sidebar_state.

SidebarGroupLabel and SidebarGroupAction add only Base UI's render, which swaps the rendered element — that is how a CollapsibleTrigger becomes a group label. SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuBadge, SidebarMenuSub, SidebarMenuSubItem, SidebarInset, SidebarSeparator and SidebarInput add no props of their own — each accepts everything its underlying element or component accepts.

Accessibility

  • SidebarInset is the main landmark; Sidebar itself is a styled div, so give the panel a landmark of your own when it is the page's navigation — an aria-labelled nav inside SidebarContent, or aria-label on the element you pass through render.
  • SidebarMenu and SidebarMenuSub are real ul lists of li rows, so the navigation is announced with a length and a position. Mark the current route with isActive: on a link it also sets aria-current="page" (A11Y-18), and a button never gets it. SidebarMenuSubButton does not do this for you, so add aria-current="page" to the active sub-link yourself.
  • A count is part of the row's name, not loose text beside it: badge and badgeLabel on SidebarMenuButton make the link read "Inbox 12 unread", and the visual badge is aria-hidden (A11Y-17).
  • Below the md breakpoint the panel mounts inside Sheet, which brings the focus trap, the scroll lock and Esc with it; SidebarTrigger opens it and focus returns to the trigger on close.
  • SidebarRail is tabIndex={-1} by design. It is the pointer affordance; SidebarTrigger and ⌘/Ctrl + B are the keyboard ones, so ship a trigger with every rail. The chord never fires while focus is in a text field or a contenteditable element (INT-11).
  • SidebarMenuAction and SidebarGroupAction are 20px glyphs that an invisible after:absolute after:-inset-2 grows to a 36px pointer target — at every width, not only on touch (A11Y-2). Give each one a real name too: an icon plus an sr-only label, as every example here does.
  • An icon-only row needs the tooltip prop, which is what names it once the panel collapses to icons.
  • Focus is the global 2px :focus-visible outline from base.css. Nothing in this component suppresses an outline or paints a ring glow.
ContractStates tested
Behaviourdefault, expanded, collapsed, open, active, disabled, loading, mobile
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, focus-visible
Visualdefault, hover, active, disabled, loading

Do / Don't

Do
Compose the shell — SidebarProvider, Sidebar, SidebarInset — and use render on SidebarMenuButton so a nav row is a real link; isActive then marks it aria-current, and badge with badgeLabel puts its count in its name.
Don't
Ship a SidebarRail as the only way to collapse the panel, or place a SidebarMenuBadge with no badgeLabel on the row — the rail is not tabbable, and the badge is aria-hidden.

Deviations

Upstream's file plus packages/ui/upstream/patches/sidebar.patch. Every hunk:

  • API-33 — SidebarMenuAction showOnHover writes data-show-on-hover and SidebarMenuBadge yields its slot to it; SidebarGroupAction takes the same showOnHover (03-10-2026), hidden from md up on a fine pointer until its group is hovered or focused, always shown on touch.
  • API-29 — SidebarMenuButton takes the sidebar-accent fill and ink on aria-expanded and data-popup-open, so a menu trigger shows its open state without a className.
  • A11Y-2 — md:after:hidden is dropped from SidebarMenuAction and SidebarGroupAction. Both are w-5 (20px) squares, which is under the 24px floor, and upstream already solves that the way A11Y-2 asks — with an invisible after:absolute after:-inset-2 hit area — and then switches the area OFF again at md and above. Measured with a real document.elementFromPoint probe over compiled Tailwind: 36×36 with 0/5 obstruction misses at 390px, and 20×20 at 1280px, i.e. 4px under the floor at every desktop width. The hit area is the only thing that changes: the visible 20px box, its position and its hover wash are upstream's. sidebar.test.tsx measures both controls at both widths.
  • A11Y-17 — SidebarMenuButton takes badge and badgeLabel. The count joins the row's accessible name through a comma-free sr-only suffix ("Inbox 12 unread"), the collapsed tooltip carries the same count, and the visual SidebarMenuBadge is aria-hidden. The button renders the badge as its upstream sibling, the label keeps its truncate while the suffix is the last child, and the row reserves pe-8 for a badge (pe-12 for one of three or more characters). In the collapsed icon rail the count stays in sight at the icon's top-end corner — a small pill up to two characters, a dot beyond (MK, 2026-10-27).
  • A11Y-18 — SidebarMenuButton sets aria-current="page" when isActive and render is a link. A button never gets it, and a caller's own aria-current wins.
  • BRD-1 — the floating variant's panel draws a real border border-sidebar-border instead of upstream's ring-1 ring-sidebar-border box-shadow outline. The floating sidebar is a floating surface, and MK extended BRD-1 to it on 23-09-2026; the edge now matches the docked variant's border-e and survives forced colours. The collapsed width already budgets +2px for it.
  • DOC-1 — registry layout: @/hooks/use-mobile becomes @/components/ui/use-mobile. Hooks are registry:hook items alongside the components in this registry, which is the path the three-copy model and shadcn add @vegastack/use-mobile both use. Plus the prettier reflow and the registry-stamp.mjs provenance header; those carry no decision of their own.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • FOC-1, FOC-6 — outline-hidden and focus-visible:ring-2 are removed from all five controls that carry them: SidebarGroupLabel, SidebarGroupAction, SidebarMenuButton, SidebarMenuAction and SidebarMenuSubButton. outline-hidden is the v3 spelling of "no outline": upstream suppresses the browser's and paints its own 2px ring instead, and this system does the opposite. ring-sidebar-ring is left in place on each — it is a colour with no ring width behind it any more, it paints nothing, and removing it would take the --sidebar-ring token's only reference with it.
  • FRM-4 — disabled:pointer-events-none and aria-disabled:pointer-events-none are dropped from SidebarMenuButton and SidebarMenuSubButton, so a disabled nav row stays hoverable and a Tooltip can explain why. Both opacity halves stay.
  • INT-11 — the ⌘/Ctrl + B handler returns early on an event that is already defaultPrevented or that started in an editable target (isEditableTarget from use-platform: input, textarea, select, contenteditable). SidebarProvider takes keyboardShortcut (default "b"; false removes the listener).
  • LAY-13 — static-shell state. New SidebarStateScript (a <head> script that reads the cookie, marks <html> with data-sidebar-state and sets the collapsed data-state and data-collapsible on the desktop panel as the parser inserts it) and useSidebarCookieOpen. The uncontrolled provider starts from that mark and keeps it in step; the cookie write gains SameSite=Lax and happens only when the provider is uncontrolled. With collapsible="icon", collapsedWhen defaults to RAIL_COLLAPSED_QUERY (768–1023px); null opts out (MK, 2026-10-26).
  • LAY-16 — SidebarMenuSkeleton's Math.random() width becomes a fixed cycle (70%, 55%, 85%, 60%, 75%) picked by a new index prop, so the server and the client render the same markup.
  • OVL-16 — the mobile panel's SheetContent restates data-[side=left]:w-(--sidebar-width) and data-[side=right]:w-(--sidebar-width), so the sheet stays at the sidebar's own mobile width (18rem) whatever width the Sheet gives a side sheet, and the scrim beside it stays tappable.
  • VOI-1 — "Toggle Sidebar" becomes "Toggle sidebar" on SidebarTrigger and SidebarRail, and both take triggerLabel to override it.

On this page