Sidebar
The provider and the parts — how a sidebar is assembled, and the full reference.
The sidebar is a provider plus a vocabulary of parts. There is no Sidebar component that takes
your navigation as data — a nav is a layout tree, and a layout tree is children. This page is
both the guide and the reference.
The shape
<SidebarProvider>
<Sidebar>
<SidebarHeader>{/* what am I looking at — a switcher */}</SidebarHeader>
<SidebarContent>{/* the navigation itself */}</SidebarContent>
<SidebarFooter>{/* who am I */}</SidebarFooter>
</Sidebar>
<SidebarInset>
<ShellMain>{children}</ShellMain> {/* the page — ShellMain owns the <main> */}
</SidebarInset>
</SidebarProvider>Each of those three slots is written out below. The app shell and workspace showcases have all three in full, wired together — that is the place to copy from.
SidebarInset carries no landmark. It is the neutral offset column that holds the content
beside the rail — a <div>, and deliberately so. Put a ShellMain inside
it: the library allows exactly one <main> per page and ShellMain is what owns it, so
nested containers below use <section>. (shadcn makes its inset the <main> because it has no
separate region layer; we do.)
SidebarProvider owns the open state, persists it to a sidebar_state cookie, and binds
⌘/Ctrl+B to toggle. Everything below reads that state through
useSidebar, which throws outside the provider. Below the md breakpoint the sidebar becomes a
Sheet drawer instead of a rail, which is why the navigating composites close it on click.
The navigation
A nav row is a SidebarMenuButton with asChild over your router's link. A group that expands
is a Collapsible around a SidebarMenuSub:
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActivePath(pathname, "/jobs")} tooltip="Jobs">
<NextLink href="/jobs"><PlayIcon />Jobs</NextLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>Wrap the whole thing in a <nav aria-label="Platform">: several labelled blocks is several
groups, one per heading, and the label is what tells them apart.
isActivePath is the one piece of that arrangement that is logic rather than markup, so it is
the one piece the library exports. It is a prefix match with a boundary: /settings is active
on /settings/cloud and not on /settings-archive. Written inline as startsWith it was
wrong at two of the call sites that had it.
Two behaviours belong to your application rather than to the library, and both are two lines in the showcases:
- A group starts open when one of its children is active —
defaultOpen={items.some((i) => isActivePath(pathname, i.href))}on theCollapsible. - Navigating closes the drawer on mobile —
useSidebar().setOpenMobile(false)in the row'sonClick. Below themdbreakpoint the sidebar is aSheet, and aSheetthat stays open over the page you just navigated to is the defect.
The identity blocks
SidebarIdentity and its parts are the shared "icon + name + subtitle" row that both a workspace
switcher and a signed-in-user footer are made of. Put a Menu around one and you have either:
<SidebarMenuButton size="lg" asChild>
<MenuTrigger>
<SidebarIdentity>
<SidebarIdentityIcon><BoxIcon /></SidebarIdentityIcon>
<SidebarIdentityText>
<SidebarIdentityLabel>aemet</SidebarIdentityLabel>
<SidebarIdentityDescription>production</SidebarIdentityDescription>
</SidebarIdentityText>
</SidebarIdentity>
</MenuTrigger>
</SidebarMenuButton>A square SidebarIdentityIcon reads as a thing; a round SidebarIdentityAvatar reads as a
person. That is the only difference between the two arrangements, and it is why they are one
vocabulary rather than two components.
The avatar takes the real Avatar parts as children, so an image, initials, a badge or a
presence dot are all available — not a URL and a fallback string.
Log out is an ordinary menu item, with variant="destructive", and your product owns the
confirmation dialog and its wording. There is no logout affordance in the library: an auth flow
does not belong in a library whose admission rules exclude auth.
Anatomy
SidebarInput is exported for the search field a sidebar header usually holds: an Input at the
rail's own height, on --background rather than the sidebar's own fill, so it reads as a field
against the panel instead of dissolving into it.
SidebarProvider
├── Sidebar
│ ├── SidebarHeader
│ ├── SidebarContent
│ │ └── SidebarGroup
│ │ ├── SidebarGroupLabel
│ │ ├── SidebarGroupAction
│ │ └── SidebarGroupContent
│ │ └── SidebarMenu
│ │ └── SidebarMenuItem
│ │ ├── SidebarMenuButton
│ │ ├── SidebarMenuAction
│ │ ├── SidebarMenuBadge
│ │ └── SidebarMenuSub
│ │ └── SidebarMenuSubItem
│ │ └── SidebarMenuSubButton
│ ├── SidebarSeparator
│ ├── SidebarFooter
│ └── SidebarRail
├── SidebarTrigger
└── SidebarInsetSidebarHeader and SidebarFooter each usually hold a Menu over a SidebarIdentity;
SidebarContent holds one <nav> per labelled block.
Sub-menus
SidebarMenuSub is hidden outright when the sidebar collapses to icons — a nested list has no
readable form at 48px, so it is dropped rather than squeezed.
Loading
Collapsing
SidebarProvider owns the state; SidebarTrigger toggles it and ⌘B is bound for you. On
narrow viewports the whole sidebar becomes a Sheet instead of collapsing in place — that swap
is automatic and is why the provider must wrap both the sidebar and the content.
Read the state anywhere below the provider:
const { open, isMobile, toggleSidebar } = useSidebar();useSidebar() is a hook, so any component calling it is a client component. Calling it from a
server component is the single most common mistake with this API.
Keyboard
| Key | Does |
|---|---|
| ⌘/Ctrl+B | Toggles the sidebar — the rail on a wide viewport, the Sheet drawer below md. |
This one is ours, not Ark's: SidebarProvider listens on window, so it fires wherever focus
is, text fields included.
API Reference
SidebarProvider
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | true |
open | boolean | — |
onOpenChange | (open: boolean) => void | — |
Extends React.ComponentProps<"div">.
Sidebar
| Prop | Type | Default |
|---|---|---|
collapsible | "offcanvas" | "icon" | "none" | "offcanvas" |
placement | "left" | "right" | "left" |
variant | "sidebar" | "floating" | "inset" | "sidebar" |
className | string | — |
collapsible="none" renders a plain static <aside> in normal flow — the right choice when
the sidebar is embedded in a panel rather than pinned to the viewport.
SidebarMenuButton
| Prop | Type | Default |
|---|---|---|
isActive | boolean | false |
tooltip | string | TooltipContentProps | — |
size | "xs" | "sm" | "md" | "lg" | … | "md" |
variant | Button variant | "ghost" |
The tooltip only shows while the sidebar is collapsed and not on mobile, so labelled buttons never get a redundant one.
SidebarContent
| Prop | Type | Default |
|---|---|---|
scrollFade | boolean | false |
SidebarMenuAction
| Prop | Type | Default |
|---|---|---|
showOnHover | boolean | false |
SidebarMenuSkeleton
| Prop | Type | Default |
|---|---|---|
showIcon | boolean | false |
SidebarMenuSubButton
| Prop | Type | Default |
|---|---|---|
isActive | boolean | false |
size | Button size | "md" |
useSidebar
Returns { state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }.
Every part's props are exported as an interface — SidebarIdentityProps and SidebarMenuBadgeProps
— so a wrapper can take the same props without restating them.