Sidebar
A collapsible app navigation rail — header / content / footer, labelled groups, menu items with active state, and an expand/collapse trigger.
- Status
- Since
0.1.0- Accessibility pattern
- navigation landmark
Last updated
Install
Add Sidebar from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/sidebarThe 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 want | pass |
|---|---|
| the default — restore the rail across reloads | nothing; 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:
<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 (controlledopen/onOpenChangeor uncontrolleddefaultOpen), the mobile Sheet'sopenMobilestate, and exposes both viauseSidebar(). Registers ⌘/Ctrl+B to toggle, and — unlesspersist={false}— writes thesidebar_statecookie on every desktop toggle (see "Persistence" below).Sidebar— the<nav>rail (data-slot="sidebar"). Carriesdata-state="expanded|collapsed",data-collapsible, anddata-variantfor 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 aSheetinstead — 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; passrender={<a />}for a nav link.isActiveapplies the accent background, leading rail, andaria-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 aSidebarMenuButtonrow.SidebarSeparator— a thin inset rule between sections.SidebarTrigger— aPanelLefticon 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 ofSidebar.SidebarInset— the main-content wrapper forvariant="inset"; render it asSidebar'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:
Menu button sizes
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:
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:
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:
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.
API Reference
SidebarProvider
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | true | Initial open state when uncontrolled. |
keyboardShortcut | string | boolean | true | Keyboard shortcut for toggling the rail. true uses b with Cmd/Ctrl;
pass a single key string to customize, or false to disable. |
mobileBreakpoint | number | 768 | Viewport 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). |
open | boolean | — | Controlled open state — pair with onOpenChange. |
persist | boolean | true | Whether 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
| Attribute | Values |
|---|---|
data-slot | "sidebar-wrapper" |
Sidebar
| Prop | Type | Default | Description |
|---|---|---|---|
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
| Attribute | Values |
|---|---|
data-collapsible | "" | "none" |
data-mobile | "true" |
data-side | mirrors a prop or state value |
data-slot | "sidebar" | "sidebar-sheet-content" |
data-state | "expanded" |
data-variant | mirrors a prop or state value |
SidebarMenuButton
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the item as the current page — applies the accent background, bold
label, and leading rail, and sets aria-current="page". |
render | useRender.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
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | 0 | This 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. |
showIcon | boolean | true | Show the leading icon-circle placeholder alongside the text line. |
Data attributes and CSS variables on SidebarMenuSkeleton
| Attribute | Values |
|---|---|
data-slot | "sidebar-menu-skeleton" |
SidebarSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
decorative | boolean | true | Whether 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
| Attribute | Values |
|---|---|
data-slot | "sidebar-separator" |
SidebarTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
render | ComponentRenderFn<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
| Attribute | Values |
|---|---|
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
Sidebarrenders a<nav>landmark — pass anaria-labelto name it (e.g. "Main navigation") so screen readers can distinguish it from other navigation regions.SidebarGroupLabelrenders 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. SidebarMenuButtonandSidebarTriggerare real<button>(or<a>) elements. On hover/press they climb the surface ladder through the sharedsurfaceInteractiverecipe (hover:bg-surface-2 active:bg-surface-3, withtext-sidebar-accent-foreground). The active row adds a leading rail and rests ondata-[active=true]:bg-surface-3, then steps back DOWN tosurface-2on hover and returns tosurface-3while pressed — so a selected row still visibly moves under the cursor. Neither element carries a per-component:focus-visiblestyle — keyboard focus relies on the design system's global:focus-visibleoutline ring (outline-ring, since they never setoutline: 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.SidebarTriggercarries anaria-label="Toggle sidebar"; the icon isaria-hidden. Its visible box is 28px — an invisible::beforehit-area expansion brings the effective target to ~44px (WCAG 2.5.8) without changing the visible icon.SidebarRailis a real, focusable<button>(not a mouse-only edge-drag handle) with the samearia-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 fromSheet. - The collapse toggle is also bound to ⌘/Ctrl+B by default; set
keyboardShortcut={false}or pass a custom key onSidebarProviderwhen embedding the sidebar in a context with conflicting shortcuts.
| Key | Action |
|---|---|
| Tab | Move focus through the trigger and menu items. |
| Enter / Space | Activate the focused menu item or trigger. |
| ⌘ / Ctrl + B | Toggle the rail between expanded and collapsed. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, checked, collapsed, disabled, expanded, loading, open |
| Accessibility | current, disabled, focus-visible, keyboard, labeled, semantic-html |
| Visual | default, hover, active, disabled, loading |
Do / Don't
Tabs
A set of layered sections — line or pill variants, optional icons and count badges, horizontal or vertical orientation, full keyboard navigation.
Page Header
The standardized header at the top of a page — back button, breadcrumb trail, title, description, actions, secondary menu, and a favorite star.