Sidebar
A composable, collapsible navigation rail — provider, panel, groups, menu rows with actions, badges and submenus, plus a rail and a trigger.
- Status
- Since
0.1.0- Accessibility pattern
- composed shell region, main landmark on the inset
Last updated
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 sidebarInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/sidebarRun 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.
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>
);
}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.
Anatomy
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
└── SidebarTriggerStructure
- 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/lilist for links, badges, actions and submenus. - SidebarRail — the thin strip on the panel's outer edge that toggles it.
- SidebarInset — the
mainlandmark that wraps the page beside the panel. - SidebarTrigger — the button that toggles the panel.
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>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>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.
| Property | Type | Description |
|---|---|---|
state | expanded or collapsed | The current state of the sidebar. |
open | boolean | Whether the sidebar is open. |
setOpen | (open: boolean) => void | Sets the open state of the sidebar. |
openMobile | boolean | Whether the sidebar is open on mobile. |
setOpenMobile | (open: boolean) => void | Sets the open state of the sidebar on mobile. |
isMobile | boolean | Whether the sidebar is on mobile. |
toggleSidebar | () => void | Toggles the sidebar. Desktop and mobile. |
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>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>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.
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>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>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>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>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>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.
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>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>
);
}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);
}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>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>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>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>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.
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>
);
}"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.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | — | |
keyboardShortcut | string | false | — | |
onOpenChange | ((open: boolean) => void) | — | |
open | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
collapsible | "icon" | "none" | "offcanvas" | — | |
side | "left" | "right" | — | |
variant | "floating" | "inset" | "sidebar" | — |
| Prop | Type | Default | Description |
|---|---|---|---|
badge | React.ReactNode | — | |
badgeLabel | string | — | |
isActive | boolean | — | |
size | "default" | "lg" | "sm" | null | — | |
tooltip | string | (TooltipPopupProps & Pick<TooltipPositionerProps, "align" | "alignOffset" | "side" | "sideOffset"> & Pick<TooltipPortalProps, "container">) | — | |
variant | "default" | "outline" | null | — |
| Prop | Type | Default | Description |
|---|---|---|---|
showOnHover | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | — | |
size | "md" | "sm" | — |
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | — | |
showIcon | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
loading | boolean | — | 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 | — | |
triggerLabel | string | — | |
variant | "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null | — |
| Prop | Type | Default | Description |
|---|---|---|---|
triggerLabel | string | — |
| Prop | Type | Default | Description |
|---|---|---|---|
collapsedWhen | string | null | RAIL_COLLAPSED_QUERY with `collapsible="icon"`, else none | A media query: with no cookie yet, a window it matches starts collapsed (and the cookie
records it); null never. |
collapsible | "icon" | "none" | "offcanvas" | — | |
cookieName | string | — | |
nonce | string | — |
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
SidebarInsetis themainlandmark;Sidebaritself is a styleddiv, so give the panel a landmark of your own when it is the page's navigation — anaria-labellednavinsideSidebarContent, oraria-labelon the element you pass throughrender.SidebarMenuandSidebarMenuSubare realullists oflirows, so the navigation is announced with a length and a position. Mark the current route withisActive: on a link it also setsaria-current="page"(A11Y-18), and a button never gets it.SidebarMenuSubButtondoes not do this for you, so addaria-current="page"to the active sub-link yourself.- A count is part of the row's name, not loose text beside it:
badgeandbadgeLabelonSidebarMenuButtonmake the link read "Inbox 12 unread", and the visual badge isaria-hidden(A11Y-17). - Below the
mdbreakpoint the panel mounts insideSheet, which brings the focus trap, the scroll lock and Esc with it;SidebarTriggeropens it and focus returns to the trigger on close. SidebarRailistabIndex={-1}by design. It is the pointer affordance;SidebarTriggerand ⌘/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 acontenteditableelement (INT-11).SidebarMenuActionandSidebarGroupActionare 20px glyphs that an invisibleafter:absolute after:-inset-2grows to a 36px pointer target — at every width, not only on touch (A11Y-2). Give each one a real name too: an icon plus ansr-onlylabel, as every example here does.- An icon-only row needs the
tooltipprop, which is what names it once the panel collapses to icons. - Focus is the global 2px
:focus-visibleoutline frombase.css. Nothing in this component suppresses an outline or paints a ring glow.
| Contract | States tested |
|---|---|
| Behaviour | default, expanded, collapsed, open, active, disabled, loading, mobile |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, labeled, focus-visible |
| Visual | default, hover, active, disabled, loading |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/sidebar.patch. Every hunk:
- API-33 —
SidebarMenuAction showOnHoverwritesdata-show-on-hoverandSidebarMenuBadgeyields its slot to it;SidebarGroupActiontakes the sameshowOnHover(03-10-2026), hidden frommdup on a fine pointer until its group is hovered or focused, always shown on touch. - API-29 —
SidebarMenuButtontakes thesidebar-accentfill and ink onaria-expandedanddata-popup-open, so a menu trigger shows its open state without aclassName. - A11Y-2 —
md:after:hiddenis dropped fromSidebarMenuActionandSidebarGroupAction. Both arew-5(20px) squares, which is under the 24px floor, and upstream already solves that the way A11Y-2 asks — with an invisibleafter:absolute after:-inset-2hit area — and then switches the area OFF again atmdand above. Measured with a realdocument.elementFromPointprobe 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.tsxmeasures both controls at both widths. - A11Y-17 —
SidebarMenuButtontakesbadgeandbadgeLabel. The count joins the row's accessible name through a comma-freesr-onlysuffix ("Inbox 12 unread"), the collapsed tooltip carries the same count, and the visualSidebarMenuBadgeisaria-hidden. The button renders the badge as its upstream sibling, the label keeps itstruncatewhile the suffix is the last child, and the row reservespe-8for a badge (pe-12for 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 —
SidebarMenuButtonsetsaria-current="page"whenisActiveandrenderis a link. A button never gets it, and a caller's ownaria-currentwins. - BRD-1 — the floating variant's panel draws a real
border border-sidebar-borderinstead of upstream'sring-1 ring-sidebar-borderbox-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'sborder-eand survives forced colours. The collapsed width already budgets+2pxfor it. - DOC-1 — registry layout:
@/hooks/use-mobilebecomes@/components/ui/use-mobile. Hooks areregistry:hookitems alongside the components in this registry, which is the path the three-copy model andshadcn add @vegastack/use-mobileboth use. Plus the prettier reflow and theregistry-stamp.mjsprovenance header; those carry no decision of their own. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - FOC-1, FOC-6 —
outline-hiddenandfocus-visible:ring-2are removed from all five controls that carry them:SidebarGroupLabel,SidebarGroupAction,SidebarMenuButton,SidebarMenuActionandSidebarMenuSubButton.outline-hiddenis 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-ringis 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-ringtoken's only reference with it. - FRM-4 —
disabled:pointer-events-noneandaria-disabled:pointer-events-noneare dropped fromSidebarMenuButtonandSidebarMenuSubButton, 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
defaultPreventedor that started in an editable target (isEditableTargetfromuse-platform: input, textarea, select,contenteditable).SidebarProvidertakeskeyboardShortcut(default"b";falseremoves the listener). - LAY-13 — static-shell state. New
SidebarStateScript(a<head>script that reads the cookie, marks<html>withdata-sidebar-stateand sets the collapseddata-stateanddata-collapsibleon the desktop panel as the parser inserts it) anduseSidebarCookieOpen. The uncontrolled provider starts from that mark and keeps it in step; the cookie write gainsSameSite=Laxand happens only when the provider is uncontrolled. Withcollapsible="icon",collapsedWhendefaults toRAIL_COLLAPSED_QUERY(768–1023px);nullopts out (MK, 2026-10-26). - LAY-16 —
SidebarMenuSkeleton'sMath.random()width becomes a fixed cycle (70%, 55%, 85%, 60%, 75%) picked by a newindexprop, so the server and the client render the same markup. - OVL-16 — the mobile panel's
SheetContentrestatesdata-[side=left]:w-(--sidebar-width)anddata-[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
SidebarTriggerandSidebarRail, and both taketriggerLabelto override it.