Sidebar
Build application navigation with desktop collapse and a mobile sheet.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/sidebarComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/avatar @siteplane/button @siteplane/card @siteplane/collapsible @siteplane/dropdown-menu @siteplane/kbd @siteplane/tooltipOr install the complete inset example with its dependencies; use @siteplane/sidebar-demo for the push layout:
pnpm dlx shadcn@4.18.0 add @siteplane/sidebar-inset-exampleOpen the full-width interactive example. Set layout="inset" on SidebarProvider for a framed content surface. The first row holds the logo and the collapse button, and the workspace switcher follows in a row of its own; when the sidebar is collapsed, hover or focus the logo's mark to reveal the expand control. Below 768 px, the header toggle opens the navigation as a sheet.
Usage
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarInset,
SidebarTrigger,
} from "@/siteplane/components/sidebar";
<SidebarProvider>
<Sidebar>
<SidebarContent>Navigation</SidebarContent>
</Sidebar>
<SidebarInset>
<SidebarTrigger />
Content
</SidebarInset>
</SidebarProvider>;Composition
SidebarProvider
Sidebar
SidebarHeader → SidebarBrand + SidebarTrigger
SidebarHeader → SidebarMenu → SidebarMenuItem (workspace switcher)
SidebarContent → SidebarGroup → SidebarGroupLabel + SidebarGroupContent
SidebarMenu → SidebarMenuItem → SidebarMenuButton + SidebarMenuAction + SidebarMenuBadge
SidebarMenuButton → SidebarMenuButtonText → SidebarMenuButtonTitle + SidebarMenuButtonDescription
SidebarMenuSub → SidebarMenuSubItem → SidebarMenuSubButton
SidebarFooter
SidebarRail
SidebarInset → SidebarTrigger + application contentThe complete examples combine these parts:
| Feature | Public composition |
|---|---|
| Application logo | SidebarBrand in a SidebarHeader |
| Named navigation groups | SidebarGroup + SidebarGroupLabel + SidebarMenu |
| Expandable nested links | Collapsible + SidebarMenuButton + SidebarMenuSub |
| Workspace and account dropdowns | DropdownMenu with a sidebar button as its trigger |
| Name over a detail line | SidebarMenuButtonText in a size="xl" menu button |
| Per-item actions | SidebarMenuAction + DropdownMenu |
| Desktop collapse and resize | SidebarTrigger + SidebarRail resizable |
| Mobile navigation | SidebarProvider + Sidebar (built-in Sheet) |
Push layout
Set layout="push" on SidebarProvider for one unframed content surface from the sidebar to the edge. The header button toggles the sidebar; expanding it pushes the header and content aside instead of covering them. SidebarInset moves its content at the sidebar's pace, so its children keep their own padding and need no offset or motion code. Put padding on those children rather than on SidebarInset: a padding utility on SidebarInset replaces the offset.
Open the full-width push example.
Layout sidebars retain their leading 8 px gutter when switching to the mobile
Sheet, matching the trailing gutter in their header, content and footer.
The account trigger uses Button's profile="avatar" for its circular portrait
and shared keyboard focus outline.
Header layouts
SidebarBrand holds the application's logo: its mark first, then its name or wordmark. It starts where the menu rows start their icons, and a header band around it keeps the height of a 48 px top bar beside it, also with a 32 px collapse button in the same row. Render it as a link with render, such as render={<a href="/" />}; it takes the kit's focus outline and no hover fill. On the collapsed icon rail it shows only its mark, in the rail's 36 px square like the row icons: the mark keeps its place while its name fades out with the rail's width, as the row labels do, and fades back in when the sidebar expands. A plain-text name gets a span of its own, so a long one ends with an ellipsis and keeps its full text in a title attribute, and on the rail it stays the link's accessible name.
The top of the sidebar takes one of three layouts. The examples use the inset layout, where the first row holds the collapse button; there, a row with the logo moves down with the framed surface beside it and stays level with the top bar on it.
Workspace on top
The workspace switcher and the collapse button share the first row. On the collapsed rail the switcher shows its company mark, which expands the sidebar through expandTrigger.
Logo above the workspace
The first row holds the logo and the collapse button; in the push layout the top bar's button toggles the sidebar and the first row holds the logo alone. The workspace switcher follows in a SidebarHeader of its own, outside SidebarContent, so it stays in place while the navigation scrolls. It shows the workspace's mark and its name over its plan, in a two-line row. On the collapsed rail the logo shows its mark, and the collapse button hides: the logo sits in a SidebarMenuItem with expandTrigger, so hovering or focusing its mark reveals the expand control at the height the collapse button had. The workspace mark below keeps opening the workspace menu. The examples at the top of this page use this layout.
Logo only
The first row holds the logo and the collapse button, without a workspace switcher. Put SidebarBrand in a SidebarMenuItem with expandTrigger, as in the layout above: on the collapsed rail, hovering or focusing the logo's mark reveals the expand control in its place, so the rail opens again from its first row.
Sidebar spacing
With a layout, the Sidebar reserves the leading 8 px gutter on its container,
so SidebarHeader, SidebarGroup and SidebarFooter add only their trailing inset
and keep their block padding. Do not override their padding to remove the
leading side. SidebarHeader and SidebarFooter are bands with 8 px above and
below, and SidebarFooter spaces its groups 16 px apart, like the groups of the
content. SidebarContent scrolls between the two bands and keeps its trailing
inset inside the scroll extent, so the last row never ends flush with, or
under, the footer card.
A sidebar sits flat on the page background in both layouts, with no frame,
border or shadow of its own; the main surface carries the edge. From the
mobile breakpoint up, SidebarInset is the primary surface beside it, with a
layout or without one, so the sidebar and the content never share one grey,
and the cards on it are white with a visible hairline.
The examples show the logo of a fictional application, Northlight, at the top, the switcher for its Acme workspace under it, and the signed-in person (initials avatar, name and email in its menu) as the account at the bottom, so none of them repeats another.
Menu row spacing
SidebarMenuButton and SidebarMenuSubButton own their inline padding: 8 px on
both sides, including text-only rows. Leading icons and trailing controls do
not supply that spacing. Do not add compensating padding to label, shortcut or
chevron wrappers. Place a 20 px icon or avatar directly before the label;
do not wrap it in an additional spacing container. On the collapsed icon rail,
main rows keep that same inset, so the icon stays where it was, and become
squares as wide as the row is high. The rail is 52 px wide, which is the 8 px
inset, the 36 px square and the 8 px inset again: the toggle, the workspace
mark, every icon and the active pill share the rail's center axis. Header and
footer items keep the inset they have in the expanded sidebar, as the rows do,
so the width animation never moves them: a 36 px item, such as the logo's
square or a row, stands on the axis, and a group of smaller controls takes the
square's width on the rail
(group-data-[collapsible=icon]:w-(--sp-rw-control-lg)) and centers them in
it. The examples stack their Messages control above the account there, so the
account stays at the bottom of the rail, where it was. Labels and
trailing content fade out and remain available to assistive technology. A
group label keeps its row, so the groups stay apart and the icons below it do
not move. Explicitly sized icons use the same margin reset as default-sized
icons.
A label that is too long for the sidebar ends with an ellipsis. In a menu row
this holds for every text span after the row's icon or avatar, also when a
chevron follows it, so the chevron stays in the row; kit parts with a slot of
their own, such as an Avatar or a Badge, keep their size. A plain-text
SidebarGroupLabel ends with an ellipsis as well and keeps its full text in a
title attribute.
SidebarContent owns scrolling. With a layout it borrows the sidebar's leading
gutter so the shared focus outline is not clipped, without changing row
alignment; without a layout its scroll viewport stays inside the sidebar and
the groups' own padding keeps the outline clear. Keep navigation groups inside
that scroll area; another overflow container can clip the outline again.
Icon collapse and resize rail
Place an icon before each menu label. On the collapsed rail only the icon stays visible, and tooltip names the item on hover or keyboard focus.
SidebarRail is an 8 px strip along the whole outer edge of the sidebar, clear of its scrollbar. A click on it toggles the sidebar. With resizable, dragging it resizes the expanded sidebar between --sidebar-width-min and --sidebar-width-max (12rem and 24rem by default). The rail stays out of the Tab order, because SidebarTrigger and Ctrl/Cmd+B toggle the sidebar from the keyboard.
Search, badges and nested links
Add SidebarInput for search, SidebarMenuBadge for counts, SidebarMenuSub for nested pages and SidebarMenuSkeleton while items load. A menu button keeps room at its end for a badge or a SidebarMenuAction, so a long label truncates before it. SidebarMenuAction renders Button's bare icon action, an icon in a square target of the control size with no hover or pressed background. With showOnHover it shows only while its row is hovered or focused, or while its menu is open.
SidebarSeparator is the kit's divider inside the sidebar's 8 px inset. It spans the band it sits in, also where the icon rail centers the band's items, and never runs past the sidebar's edge.
Two-line rows
A row can show a name over a quieter detail line, such as a workspace and its plan or an account and its email. Put SidebarMenuButtonText after the row's avatar or icon, with SidebarMenuButtonTitle and SidebarMenuButtonDescription inside it. The title keeps the row's text. The description is one kit text step smaller (12 px on an 18 px line, or 14 px on a 21 px line in an xl row) in the tertiary text color. Both stay on one line, end with an ellipsis when they are too long, and keep their full text in a title attribute when they are plain text. The text takes the row's free width, so a trailing chevron stays at the row's end. Two lines fill the 48 px row, so give the button size="xl". The collapsed icon rail holds 36 px squares with a 20 px icon: in a sidebar that collapses to it, give the row size="lg" and its avatar the 20 px of a row icon (size-5) while useSidebar().state is "collapsed", so the avatar stays on the rail's center axis. The row follows the switch at the sidebar's pace: its height, padding and text and the avatar's size change with the rail's width, so the rows below glide up instead of jumping. Reduced motion makes the change instant.
Right-to-left layouts
side is the inline start or end of the page. In a right-to-left page, side="left" puts the sidebar on the right, together with its gap, its rail and the sheet on small screens, and a resizable rail moves its edge the other way. Badges, group actions and the nested guide line sit at the row's inline end or start, collapsed-rail tooltips open toward the content, and the default trigger glyph mirrors.
Persist the state
On every real change of the desktop state, the provider writes it to a cookie named sidebar_state, for seven days. It never reads the cookie back, so read it where the page renders and pass it to defaultOpen; the first render then matches the last choice. In Next.js, a nested layout for the pages with the sidebar can read it on the server:
import { cookies } from "next/headers";
import { SidebarProvider } from "@/siteplane/components/sidebar";
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false";
return (
<SidebarProvider defaultOpen={defaultOpen}>{children}</SidebarProvider>
);
}Set stateCookie to another name when several sidebars share a domain, for example an application sidebar and a settings sidebar, or to false to write no cookie.
Controlled desktop state
open and onOpenChange control desktop expansion. Mobile sheet state remains separate.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Sidebar | "inset" | "push" | — | Application layout; DashboardShell sets it. |
Sidebar | boolean | true / uncontrolled | Initial or controlled desktop expansion. |
Sidebar | "md" | "lg" | "md" | Switch to the mobile Sheet below 768 px (md) or 1024 px (lg), the widths of the md: and lg: utilities. |
Sidebar | string | false | "sidebar_state" | Name of the cookie that keeps the desktop state, or false for no cookie. The provider writes it and never reads it. |
Sidebar | boolean | true | Register Ctrl/Cmd+B. Disable for an auxiliary navigation that must not compete with the application sidebar. |
Sidebar | (open: boolean) => void | — | Desktop expansion changes. |
Sidebar side / variant / collapsible | left | right / sidebar | floating | inset / offcanvas | icon | none | left / sidebar / offcanvas | Attachment, surface, and collapse behavior; none remains static on mobile. left and right are the inline start and end, so a left sidebar sits on the right in a right-to-left page. |
Sidebar. | default | fullscreen | default | Mobile navigation surface. default slides in from the sidebar's side at the mobile width (18rem); fullscreen fills the viewport and fades in from the top. |
Sidebar | Button props | ghost, square at the provider size (icon-lg) | Toggles desktop or mobile state and announces it with aria-expanded and aria-controls; invokes supplied onClick before toggling. |
Sidebar | boolean | false | Pointer resize within CSS width limits; rail is excluded from Tab navigation. Supplied onClick and onPointerDown run before the rail's own. |
Sidebar | boolean | false | Places SidebarTrigger over the item's row. On the collapsed icon rail, hover or focus swaps the row for the trigger, so a workspace mark also expands the sidebar. A SidebarBrand in the item steps aside the same way. |
Sidebar | string | "Expand sidebar" | Accessible name of the expand trigger. |
Sidebar | boolean / string | TooltipPopup props | false / — | active shows the active style and sets aria-current="page" unless you pass aria-current, which shows the active style as well. isActive is a deprecated alias of active. tooltip names the item on the collapsed desktop rail. |
Sidebar | sm | md | lg | xl / default | outline | workspace | provider (lg) / default | Menu density and treatment. |
Sidebar | Element or render callback | button | Router/link composition. |
Sidebar | span props | — | A two-line row: the title in the row's text over a description one text step smaller in the tertiary color. Both truncate, and plain text keeps its full value in title unless you pass one. |
Sidebar | boolean | false | Reveal action on hover/focus; accepts button useRender props. |
Sidebar | boolean / string / "sm" | "md" | "lg" | "xl" | false / "72%" / provider (lg) | Loading row shape, as high as a menu row of the same size. |
Sidebar | "sm" | "md" | "lg" | "xl" / boolean | provider (lg) / false | Nested button dimensions/state; active also sets aria-current="page", and a passed aria-current shows the active style. isActive is a deprecated alias of active. Accepts ButtonBaseProps except size. |
Sidebar | useRender div / button props | div / button | Group labels and actions with render composition. A plain-text label ends with an ellipsis and keeps its full text in title. |
Sidebar | main props | — | Application content beside the sidebar: the primary surface from the mobile breakpoint up, with a layout or without one. In the push layout it moves its content clear of the expanded sidebar. |
Sidebar | useRender div props | div | The application's logo: its mark first, then its name or wordmark. It starts at the rows' 8 px icon inset and is as tall as the rail's 36 px square, reaching 2 px into the band's padding, so the band stays 48 px. On the collapsed icon rail only the mark shows, in that square, and keeps its place while the name fades out. A plain-text name ends with an ellipsis and keeps its full text in title. Render a link with render; in a SidebarMenuItem with expandTrigger it steps aside for the expand control on the rail. |
Sidebar | div props | — | Layout parts; content supplies scrolling and space for the shared focus outline. Do not add a nested scrolling wrapper around menu groups. |
Sidebar | ul / ul / li / li props | — | List semantics. |
Sidebar | Input / Separator props | Kit defaults | Styled input, and the kit divider inside the sidebar's 8 px inset. |
use | SidebarContextProps | Provider required | layout, state, open, setOpen, openMobile, setOpenMobile, isMobile, mobileBreakpoint, toggleSidebar. |
Sidebar | React.Context<SidebarContextProps | null> | null | Public context, normally read through useSidebar. |
sidebar | string | "w-56" | Width utility for menus attached to the sidebar. |
SidebarLayout is the exported "inset" | "push" union used by the provider and context.
Usage notes
The provider registers Ctrl/Cmd+B unless keyboardShortcut is false; the shortcut leaves the keys to a focused text field or editor, to input methods and to handlers that already took the event. Desktop changes write the state cookie (path /) for seven days, sidebar_state unless stateCookie names another or turns it off, using the Cookie Store API where the browser provides it and document.cookie otherwise; if cookies are blocked, the sidebar still toggles. A controlled state is written once the parent applies the change. Initial state is not read back automatically: a server or application must read the cookie and pass defaultOpen, as in Persist the state. A collapsed offcanvas sidebar leaves the tab order and the accessibility tree once it has slid out; its rail stays available to open it. Mobile navigation uses Sheet and starts closed; its dialog takes the Sidebar's aria-label as its title ("Sidebar" without one), and closing it returns focus to the SidebarTrigger that opened it. On desktop, id, aria-label, aria-labelledby and aria-describedby go to the aside landmark, while className, style and the other props go to the sidebar container; on mobile every prop reaches the sheet. Set mobileVariant="fullscreen" on Sidebar for a full-viewport navigation surface. Nested navigation pages can compose Sheet with variant="fullscreen"; the Sheet owns focus, Escape and return focus. Set mobileBreakpoint="lg" when a header switches to mobile navigation below 1024 px. SidebarTrigger accepts children to customize its icon; provide an accessible label when replacing the default content. --sidebar-width defaults to 16rem, icon width to 3.25rem, resize bounds to 12rem/24rem, and mobile width to 18rem. Set desktop custom properties through provider style. The bounded preview wrapper is included in the source; remove it for a full viewport application. Native element attributes and the linked Button, Input and Tooltip APIs apply to their respective parts.
In the complete examples, workspace selection and the active navigation item update locally. Replace the sample data and hash links with your routes, and connect search, account, billing and project actions to your application. Keyboard shortcut labels are illustrative; only Ctrl/Cmd+B is registered by the sidebar provider.
The preview runs in its own document viewport so navigation landmarks and responsive behavior stay independent of the docs.