Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Sidebar

A collapsible app navigation rail — header / content / footer, labelled groups, menu items with active state, and an expand/collapse trigger.

Status
stable
Since
0.1.0
Accessibility pattern
navigation landmark

Last updated

Workspace
Select a navigation item

Install

Add Sidebar from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/sidebar

The same command installs the registry items it composes: @vegastack/icon-button, @vegastack/separator, @vegastack/sheet, @vegastack/skeleton, @vegastack/use-mobile.

Usage

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";

<SidebarProvider>
  <Sidebar aria-label="Main navigation">
    <SidebarHeader>…logo / workspace switcher…</SidebarHeader>
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Workspace</SidebarGroupLabel>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton isActive render={<a href="/" />}>
              <Home />
              <span>Home</span>
            </SidebarMenuButton>
          </SidebarMenuItem>
          <SidebarMenuItem>
            <SidebarMenuButton render={<a href="/inbox" />}>
              <Inbox />
              <span>Inbox</span>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarGroup>
    </SidebarContent>
    <SidebarFooter>
      <SidebarMenuButton render={<a href="/settings" />}>
        <Settings />
        <span>Settings</span>
      </SidebarMenuButton>
    </SidebarFooter>
  </Sidebar>
  <div className="flex min-w-0 flex-1 flex-col">
    <header>
      {/* Always mounted: collapses the desktop rail and opens the mobile Sheet. */}
      <SidebarTrigger />
    </header>
    {/* …your page content… */}
  </div>
</SidebarProvider>;

Persistence

While persist is on — it is by default — every desktop toggle writes a sidebar_state cookie (path=/, max-age ~1 year) so the sidebar can restore its state across page loads. The cookie is the only piece of application policy this component holds, so it is deliberately switchable:

you wantpass
the default — restore the rail across reloadsnothing; persist defaults to true
no cookie at all (consent regimes, your own store)persist={false}, and persist from onOpenChange yourself

persist={false} gates the write only. onOpenChange still fires on every toggle either way, so nothing about the component's behaviour changes except that it stops touching document.cookie.

SidebarProvider only ever WRITES the cookie, client-side, in response to a user action; it never reads document.cookie at render (that would differ between server and client and trigger a hydration mismatch). To restore state on the next load, read the cookie in your server layout and pass it as defaultOpen:

// app/layout.tsx (Server Component)
import { cookies } from "next/headers";

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const defaultOpen = (await cookies()).get("sidebar_state")?.value !== "false";
  return (
    <SidebarProvider defaultOpen={defaultOpen}>{children}</SidebarProvider>
  );
}

Anatomy

Sidebar is a compound component. SidebarProvider owns the expanded/collapsed state and lays the rail out next to your page content; everything else composes inside the <nav> landmark:

Sidebar — data-slot="sidebar" | "sidebar-sheet-content"
SidebarContent — data-slot="sidebar-content"
SidebarFooter — data-slot="sidebar-footer"
SidebarGroup — data-slot="sidebar-group"
SidebarGroupLabel — data-slot="sidebar-group-label"
SidebarHeader — data-slot="sidebar-header"
SidebarInset — data-slot="sidebar-inset"
SidebarMenu — data-slot="sidebar-menu"
SidebarMenuBadge — data-slot="sidebar-menu-badge"
SidebarMenuButton
SidebarMenuItem — data-slot="sidebar-menu-item"
SidebarMenuSkeleton — data-slot="sidebar-menu-skeleton"
SidebarProvider — data-slot="sidebar-wrapper"
SidebarRail — data-slot="sidebar-rail"
SidebarSeparator — data-slot="sidebar-separator"
SidebarTrigger — data-slot="sidebar-trigger"
<SidebarProvider defaultOpen>
  <Sidebar aria-label="Main navigation">
    <SidebarHeader>…app switcher / logo + SidebarTrigger…</SidebarHeader>
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Group</SidebarGroupLabel>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton isActive>
              <Icon />
              <span>Label</span>
            </SidebarMenuButton>
            <SidebarMenuBadge>3</SidebarMenuBadge>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarGroup>
      <SidebarSeparator />
    </SidebarContent>
    <SidebarFooter>…user menu…</SidebarFooter>
  </Sidebar>
</SidebarProvider>
  • SidebarProvider — owns the open state (controlled open/onOpenChange or uncontrolled defaultOpen), the mobile Sheet's openMobile state, and exposes both via useSidebar(). Registers /Ctrl+B to toggle, and — unless persist={false} — writes the sidebar_state cookie on every desktop toggle (see "Persistence" below).
  • Sidebar — the <nav> rail (data-slot="sidebar"). Carries data-state="expanded|collapsed", data-collapsible, and data-variant for descendant styling, and animates its width between states. On desktop it stays anchored to the viewport while the adjacent document scrolls. Below the mobile breakpoint (768px by default) it renders its children inside a Sheet instead — see "Mobile" below.
  • SidebarHeader / SidebarFooter — the non-shrinking top and bottom regions (logo / app switcher, user menu). The footer remains at the viewport bottom on desktop.
  • SidebarContent — the flexible, scrollable middle region that holds the groups. When navigation is taller than the viewport, scroll this region rather than the header/footer or entire rail.
  • SidebarGroup + SidebarGroupLabel — a labelled section; the label fades out when collapsed.
  • SidebarMenu / SidebarMenuItem — the <ul> / <li> list structure for nav entries.
  • SidebarMenuButton — the interactive row. Renders a <button> by default; pass render={<a />} for a nav link. isActive applies the accent background, leading rail, and aria-current="page".
  • SidebarMenuBadge — an optional count/status pill on a menu item; it becomes a compact status dot in the collapsed icon rail while retaining its text for assistive technology.
  • SidebarMenuSkeleton — a loading placeholder shaped like a SidebarMenuButton row.
  • SidebarSeparator — a thin inset rule between sections.
  • SidebarTrigger — a PanelLeft icon button that toggles the rail (or, on mobile, the Sheet); place it in a header.
  • SidebarRail — a thin click-to-toggle edge strip; render it as a child of Sidebar.
  • SidebarInset — the main-content wrapper for variant="inset"; render it as Sidebar's sibling.

Examples

Multiple labelled groups, each with its own menu items:

The collapsed icon rail — labels and group headings hide, leaving only icons (toggle it with the trigger):

Right edge

Set side="right" to dock the rail on the trailing edge — it picks up a left border and orders itself after the page content:

Page content sits to the left of a right-edge rail.

SidebarMenuButton accepts a size of sm (h-7), md (h-8), or lg (h-10):

Controlled mode

Drive the open state yourself with open / onOpenChange on SidebarProvider, and read or toggle it from any descendant with useSidebar() — here an external button outside the rail:

State from useSidebar(): expanded (open: true)

Variants

variant="floating" detaches the rail into its own bordered, shadowed panel:

variant="inset" keeps the rail flush, and instead makes SidebarInset (the main content) the rounded/bordered/shadowed panel. Pair it with SidebarRail for a click-to-toggle edge strip alongside the explicit trigger:

Page content sits in the rounded inset panel; drag the thin edge strip (SidebarRail) or use the trigger to collapse the rail.

Off-canvas collapse

collapsible="offcanvas" slides the rail fully off-screen (instead of shrinking to an icon rail) when toggled — page content reflows to fill the space:

Toggle the trigger — the rail slides fully off-screen instead of shrinking to icons.

collapsible="none" is also available for a rail that should never collapse at all (and never becomes the mobile Sheet) — useful inside a fixed-size panel where collapsing doesn't make sense.

Loading state

SidebarMenuSkeleton composes Skeleton into an icon-circle + text-line row shaped like SidebarMenuButton. Pass each row's index so the text-line widths vary — deterministically, off a small fixed cycle, never Math.random() — instead of every row rendering identically:

Mobile

Below the mobile breakpoint (768px by default — override with SidebarProvider's mobileBreakpoint), Sidebar renders its children inside a Sheet (side="left" by default, following the side prop) instead of the static rail — you get focus trapping, scroll locking, and Escape-to-close for free from Sheet. SidebarProvider tracks this as openMobile, separate from desktop open: collapsing to icons and sliding a Sheet in are different interactions that can't share one boolean. useSidebar() also exposes isMobile if you need to branch on it directly.

Place SidebarTrigger (or any custom control that calls toggleSidebar()) OUTSIDE Sidebar — typically in your persistent page header — not inside SidebarHeader. Below the breakpoint, Sidebar's own children (the trigger included, if nested inside it) only mount once the Sheet is already open; nesting the one control that opens it in there is a dead end. A layout that needs to work at every width composes it like this:

<SidebarProvider>
  <SidebarTrigger />{" "}
  {/* outside Sidebar — always mounted, opens the Sheet on mobile */}
  <Sidebar aria-label="Main navigation">…</Sidebar>
  <SidebarInset>…page content…</SidebarInset>
</SidebarProvider>

Switch the preview's width toggle (the toolbar's phone icon) to mobile to watch the rail collapse into a Sheet you open from the header trigger. This is viewport-driven in a real app; the toggle only constrains a container, so this demo forces the branch to make it visible without resizing your window.

Mobile workspace
Navigation opens over this content.

API Reference

SidebarProvider

PropTypeDefaultDescription
defaultOpenbooleantrueInitial open state when uncontrolled.
keyboardShortcutstring | booleantrueKeyboard shortcut for toggling the rail. true uses b with Cmd/Ctrl; pass a single key string to customize, or false to disable.
mobileBreakpointnumber768Viewport width (px) below which Sidebar switches into the mobile Sheet mode. Forwarded to useIsMobile.
onOpenChange((open: boolean) => void)Called whenever the open state changes (in both modes).
openbooleanControlled open state — pair with onOpenChange.
persistbooleantrueWhether a desktop toggle writes the sidebar_state cookie so the next page load can restore the rail. On by default, because the flash of a wrongly collapsed rail is the thing everyone hits first. Persistence policy is the HOST's, though — cookie banners, consent regimes, a store of your own — so pass persist={false} to keep the component out of document.cookie entirely and drive the state yourself from onOpenChange, which fires identically either way.

Data attributes and CSS variables on SidebarProvider

AttributeValues
data-slot"sidebar-wrapper"
PropTypeDefaultDescription
collapsible"icon" | "none" | "offcanvas"'icon'How the rail collapses when state is "collapsed". - icon (default — the pre-existing behavior): shrinks to --sidebar-width-icon, labels hide (sr-only, stay in the accessible name). - offcanvas: slides fully off-screen (translate) and its width drops to 0, so page content reflows to fill the space. - none: never collapses (and never becomes the mobile Sheet) — always renders at --sidebar-width. SidebarTrigger/SidebarRail/toggleSidebar become no-ops for it.
side"left" | "right"'left'Which edge the sidebar sits on. Also controls which edge the mobile Sheet slides in from.
variant"floating" | "inset" | "sidebar"'sidebar'Visual treatment (desktop only — the mobile Sheet always uses its own panel styling). - sidebar (default): flush rail, bordered against the page edge. - floating: a detached panel — margin on every edge, its own border/radius/shadow. - inset: same rail treatment as sidebar; pair it with SidebarInset on the main content, which becomes the rounded/bordered/shadowed panel instead.

Data attributes and CSS variables on Sidebar

AttributeValues
data-collapsible"" | "none"
data-mobile"true"
data-sidemirrors a prop or state value
data-slot"sidebar" | "sidebar-sheet-content"
data-state"expanded"
data-variantmirrors a prop or state value

SidebarMenuButton

PropTypeDefaultDescription
isActivebooleanfalseMarks the item as the current page — applies the accent background, bold label, and leading rail, and sets aria-current="page".
renderuseRender.RenderProp<Record<string, unknown>>Replace the rendered element via Base UI render composition. Pass an <a> for navigation while keeping the styling.
size"lg" | "md" | "sm"

SidebarMenuSkeleton

PropTypeDefaultDescription
indexnumber0This row's position when rendering several skeleton rows in a loop (e.g. the array index). Selects a width from SIDEBAR_MENU_SKELETON_WIDTHS deterministically (index % length), so consecutive rows vary in width without any randomness.
showIconbooleantrueShow the leading icon-circle placeholder alongside the text line.

Data attributes and CSS variables on SidebarMenuSkeleton

AttributeValues
data-slot"sidebar-menu-skeleton"

SidebarSeparator

PropTypeDefaultDescription
decorativebooleantrueWhether the separator is purely visual. When true (the default) it is hidden from assistive tech (role="presentation", aria-hidden) since the surrounding layout already conveys the grouping. Set to false when the divider carries semantic meaning (e.g. separating menu sections) so screen readers announce it as a separator.
orientation"horizontal" | "vertical"'horizontal'Axis the separator divides along. horizontal renders a 1px-tall full-width rule; vertical renders a 1px-wide full-height rule.

Data attributes and CSS variables on SidebarSeparator

AttributeValues
data-slot"sidebar-separator"

SidebarTrigger

PropTypeDefaultDescription
renderComponentRenderFn<HTMLProps, ButtonState> | React.ReactElement<unknown, string | React.JSXElementConstructor<any>>Replace the rendered element (Base UI composition). Typed from IconButton, which renders this control, so a render function receives the real ButtonState.

Data attributes and CSS variables on SidebarTrigger

AttributeValues
data-slot"sidebar-trigger"

SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarMenuBadge, SidebarRail, and SidebarInset add no props of their own — each accepts everything its underlying element accepts (<div>, <h3>, <ul>, <li>, <span>, <button>, <main>), plus className and ref.

Accessibility

  • Sidebar renders a <nav> landmark — pass an aria-label to name it (e.g. "Main navigation") so screen readers can distinguish it from other navigation regions.
  • SidebarGroupLabel renders a heading for grouped navigation content.
  • The active item sets aria-current="page" in addition to the visual highlight, so assistive tech announces the current location.
  • SidebarMenuButton and SidebarTrigger are real <button> (or <a>) elements. On hover/press they climb the surface ladder through the shared surfaceInteractive recipe (hover:bg-surface-2 active:bg-surface-3, with text-sidebar-accent-foreground). The active row adds a leading rail and rests on data-[active=true]:bg-surface-3, then steps back DOWN to surface-2 on hover and returns to surface-3 while pressed — so a selected row still visibly moves under the cursor. Neither element carries a per-component :focus-visible style — keyboard focus relies on the design system's global :focus-visible outline ring (outline-ring, since they never set outline: none). Consumers who do not load the global focus rule fall back to the user-agent default outline, so verify a visible focus indicator in your own shell.
  • SidebarTrigger carries an aria-label="Toggle sidebar"; the icon is aria-hidden. Its visible box is 28px — an invisible ::before hit-area expansion brings the effective target to ~44px (WCAG 2.5.8) without changing the visible icon.
  • SidebarRail is a real, focusable <button> (not a mouse-only edge-drag handle) with the same aria-label/title, so keyboard users can reach and operate it too.
  • On mobile, the rail renders inside Sheet — focus trapping, scroll locking, and Escape-to-close all come from Sheet.
  • The collapse toggle is also bound to /Ctrl+B by default; set keyboardShortcut={false} or pass a custom key on SidebarProvider when embedding the sidebar in a context with conflicting shortcuts.
KeyAction
TabMove focus through the trigger and menu items.
Enter / SpaceActivate the focused menu item or trigger.
/ Ctrl + BToggle the rail between expanded and collapsed.
ContractStates tested
Behaviourdefault, active, checked, collapsed, disabled, expanded, loading, open
Accessibilitycurrent, disabled, focus-visible, keyboard, labeled, semantic-html
Visualdefault, hover, active, disabled, loading

Do / Don't

Do
Use render={<a />} on SidebarMenuButton for navigation links, keep one item isActive to reflect the current route, and name the <nav> with aria-label.
Don't
Use a plain Button for nav rows (loses active semantics), or rely on color alone — the active item also sets aria-current and a leading rail.

On this page