Sidebar
The application shell sidebar — a composable part-family ported from shadcn’s sidebar. One SidebarProvider owns the open/collapsed state (and persists it to a cookie); everything else is layout you assemble: a Sidebar container, a SidebarInset for the main content, and the SidebarHeader / SidebarContent / SidebarFooter / SidebarGroup / SidebarMenu slots in between.
This is what web/wms renders for its whole app chrome (collapsible="icon", variant="inset").
The Provider is required
Every part reads state through useSidebar(), which throws outside a SidebarProvider. Wrap the whole layout — both the Sidebar and the SidebarInset — in one provider, normally at the root of your app so the cookie-restored state applies before first paint.
import { SidebarProvider, Sidebar, SidebarInset } from "@vesyl/ui-next";
<SidebarProvider>
<Sidebar>{/* header / content / footer */}</Sidebar>
<SidebarInset>{/* page */}</SidebarInset>
</SidebarProvider>;The provider renders a full-height (min-h-svh), full-width flex wrapper and supplies the --sidebar-width (16rem) and --sidebar-width-icon (3rem) CSS variables. It also mounts a TooltipProvider, so SidebarMenuButton tooltips work without extra setup. The demos on this page override the height to a fixed 420px box so they fit inside the preview — in a real app you let it fill the viewport.
State & persistence
SidebarProvideris uncontrolled by default. Passopen+onOpenChangeto drive it yourself.- Toggling writes a
sidebar_statecookie (7-day max-age). WithoutdefaultOpen, the provider reads that cookie on first render, so the open/collapsed choice survives reloads. SSR can read the same cookie and passdefaultOpento avoid a flash. - Below
mobileBreakpoint("md", 768px, by default;"sm"is 640px), the sidebar becomes aSheetdrawer, full width on phones. Following a link closes it. Open it withSidebarMobileTrigger, a hamburger button that renders only while the drawer is in use, so it can sit in a page header without a breakpoint class. open/setOpendrive the desktop sidebar;openMobile/setOpenMobiledrive the drawer.toggleSidebar()picks the right one.- The sidebar publishes the width it occupies as
--app-sidebar-widthon the document root, so portaled surfaces (e.g.Sheetsized to the main column) can stay beside it.
Composition
Assemble the parts inside Sidebar. The common shape is a SidebarHeader (branding / org switcher), a scrollable SidebarContent holding one or more SidebarGroups, and a SidebarFooter (user menu, support). Inside a group, a SidebarMenu (<ul>) holds SidebarMenuItems (<li>), each wrapping a SidebarMenuButton. Drop a lucide icon plus a <span> label inside the button.
SidebarTrigger is a ghost icon button that calls toggleSidebar(), labelled “Collapse sidebar” / “Expand sidebar” with a matching tooltip. Drop it in your page header (inside SidebarInset), or in the sidebar itself. Inside an expanded sidebar it dims until hovered or focused; inside the mobile drawer it becomes an X that closes the drawer. SidebarInset is the <main> region beside the sidebar; it grows to fill the remaining width and, under variant="inset", gets the rounded, inset card treatment.
Linking menu buttons
SidebarMenuButton (and SidebarMenuSubButton) render a <button> by default. To make one a router link, pass render with your framework’s Link — the part merges its classes onto your element via Base UI’s useRender, so the typed Link is never erased. Mark the current route with isActive (sets data-active, which drives the active styling).
import { Link } from "@tanstack/react-router";
<SidebarMenuButton isActive={isActive} render={<Link to="/orders" />}>
<Inbox />
<span>Orders</span>
</SidebarMenuButton>;Collapsible to icons
collapsible="icon" (vs. the default "offcanvas", or "none") keeps the rail visible when collapsed, narrowing it to --sidebar-width-icon so only the leading icons show. Each SidebarMenuButton shows its <span> label as a right-side tooltip only while collapsed; pass tooltip to say something else, or tooltip={false} to turn it off. Add a SidebarRail for the thin click-to-toggle strip on the sidebar’s edge.
The rest of the family adapts to the icon column too:
SidebarLogoswaps its children for amarkand sizes itself like a menu button, so the mark lines up with the icons.SidebarHeaderRowlays a logo and aSidebarTriggerside by side when expanded, and stacks them (trigger on top) on the icon column.SidebarSeparatorshortens to a mark under the icons, andSidebarFooter bordereddraws a rule that does the same.SidebarMenuButton size="monogram"holds a short text code (e.g. a location code) in place of an icon.hasActiveChildmarks a group trigger whose child page is current.SidebarGroup variant="recessed"sets an expanded cluster into the sidebar surface.
Everything on the icon column stays anchored to the left edge, where the icons sit in both states. Collapsing therefore reads as the column closing over the labels rather than the rows re-centering, and the sidebar clips its contents while the width animates. Pass animate={false} to SidebarProvider to snap the width instead, e.g. when the main pane is too heavy to reflow every frame. Reduced-motion users always get the snap.
<SidebarHeader>
<SidebarHeaderRow>
<SidebarLogo render={<Link to="/" />} mark={<LogoMark />}>
<Logo />
</SidebarLogo>
<SidebarTrigger />
</SidebarHeaderRow>
</SidebarHeader>In icon mode the SidebarGroupLabel, SidebarMenuSub, SidebarMenuAction, and SidebarMenuBadge parts hide themselves automatically, and SidebarMenuButton collapses to a square. Below mobileBreakpoint the sidebar falls back to the mobile Sheet drawer.
Nested menus, badges & actions
A menu item can carry more than a button. Wrap the item in a Collapsible and nest a SidebarMenuSub (with SidebarMenuSubItem / SidebarMenuSubButton) for a second level. SidebarMenuBadge pins a count to the trailing edge, and SidebarMenuAction adds a secondary control (here the collapse chevron) — pass showOnHover to keep it hidden until the row is hovered or focused.
Variants & sides
Sidebar takes three layout knobs:
variant—"sidebar"(default, flush to the edge),"floating"(detached, rounded, bordered card), or"inset"(the sidebar sits in the page margin and theSidebarInsetbecomes a rounded inset card).web/wmsuses"inset".side—"left"(default) or"right".collapsible—"offcanvas"(default; slides fully off-screen),"icon"(collapses to the icon rail), or"none"(a static, non-collapsing column — what the demos above use so they render predictably in a small box).
Other parts
A few slots aren’t shown in the demos above but are part of the family:
SidebarInput— a sidebar-styledInputfor an in-rail search/filter box; place it in theSidebarHeader.SidebarSeparator— a sidebar-tunedSeparator(correct color + inset margins).SidebarGroupAction— an action button pinned to aSidebarGroupLabel(e.g. a ”+” to add to that group).SidebarMenuSkeleton— a loading placeholder row (optionalshowIcon) for menus that hydrate from data.
// Loading state for a data-backed menu
<SidebarMenu>
{Array.from({ length: 5 }).map((_, i) => (
<SidebarMenuItem key={i}>
<SidebarMenuSkeleton showIcon />
</SidebarMenuItem>
))}
</SidebarMenu>Reference
Parts
| Part | Element | Description |
|---|---|---|
SidebarProvider | div | Owns state + cookie; supplies CSS vars and a TooltipProvider. Required wrapper. |
Sidebar | div | The sidebar container. Props: side, variant, collapsible |
SidebarInset | main | The main content region beside the sidebar; rounds into a card under variant="inset" |
SidebarTrigger | Button | Ghost icon button that calls toggleSidebar() |
SidebarMobileTrigger | Button | Hamburger that opens the drawer; renders nothing when the sidebar is on screen |
SidebarRail | button | Thin click-to-toggle strip on the sidebar’s edge |
SidebarHeader | div | Top slot (branding, switchers) |
SidebarHeaderRow | div | Logo + trigger row; stacks on the icon column |
SidebarLogo | div* | Brand slot. Prop: mark (shown while collapsed to icons) |
SidebarContent | div | Scrollable middle slot; holds groups |
SidebarFooter | div | Bottom slot (user menu, support). Prop: bordered |
SidebarSeparator | Separator | Sidebar-tuned divider |
SidebarInput | Input | Sidebar-styled input for an in-rail search box |
SidebarGroup | div | A labeled section of the content. Prop: variant |
SidebarGroupLabel | div* | Group heading; hides in icon mode |
SidebarGroupAction | button* | Action pinned to a group label |
SidebarGroupContent | div | Wrapper for a group’s body |
SidebarMenu | ul | List of menu items |
SidebarMenuItem | li | One menu row |
SidebarMenuButton | button* | The clickable row. Props: isActive, hasActiveChild, variant, size, tooltip |
SidebarMenuAction | button* | Secondary control on a row. Prop: showOnHover |
SidebarMenuBadge | div | Trailing count/badge on a row |
SidebarMenuSkeleton | div | Loading placeholder row. Prop: showIcon |
SidebarMenuSub | ul | Nested sub-menu list; hides in icon mode |
SidebarMenuSubItem | li | One sub-menu row |
SidebarMenuSubButton | a* | The clickable sub-row. Props: isActive, size |
* Parts marked with an asterisk accept a render prop (Base UI useRender) to swap the underlying element — e.g. a router Link.
SidebarProvider
Extends React.ComponentProps<'div'>.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | cookie, then true | Initial open state (uncontrolled) |
open | boolean | — | Controlled open state |
onOpenChange | (open: boolean) => void | — | Fires when open state changes (controlled) |
mobileBreakpoint | "sm" | "md" | "md" | Where the sidebar becomes a drawer (640px or 768px) |
animate | boolean | true | false snaps width changes instead of animating them |
className | string | — | Merged onto the wrapper |
style | CSSProperties | — | Merged after the --sidebar-width* vars |
Sidebar
Extends React.ComponentProps<'div'>.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "left" | "right" | "left" | Which edge the sidebar docks to |
variant | "sidebar" | "floating" | "inset" | "sidebar" | Flush, detached card, or inset (page-margin) treatment |
collapsible | "offcanvas" | "icon" | "none" | "offcanvas" | Slide off-screen, collapse to an icon rail, or never collapse |
SidebarMenuButton
Extends React.ComponentProps<'button'> and Base UI useRender.ComponentProps<'button'>.
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the current route; sets data-active and the active styling |
hasActiveChild | boolean | false | A page under this row is current; sets data-active-child |
variant | "default" | "outline" | "default" | Plain row, or a bordered/elevated row |
size | "md" | "sm" | "lg" | "monogram" | "md" | Row height / text size; monogram holds a short code in place of an icon |
tooltip | string | TooltipContent props | false | the <span> label | Right-side tooltip while collapsed (icon mode); false turns it off |
render | ReactElement | (props, state) => ReactElement | — | Swap the underlying element (e.g. a router Link) |
SidebarMenuSubButton
Extends React.ComponentProps<'a'> and Base UI useRender.ComponentProps<'a'>.
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the current sub-route |
size | "sm" | "md" | "md" | Text size |
render | ReactElement | fn | — | Swap the underlying element |
SidebarMenuAction
Extends React.ComponentProps<'button'> and useRender.ComponentProps<'button'>.
| Prop | Type | Default | Description |
|---|---|---|---|
showOnHover | boolean | false | Keep hidden until the menu item is hovered or focused |
SidebarMenuSkeleton
Extends React.ComponentProps<'div'>.
| Prop | Type | Default | Description |
|---|---|---|---|
showIcon | boolean | false | Also render a leading icon skeleton |
useSidebar()
Read or drive sidebar state from anywhere inside the provider. Throws if called outside SidebarProvider.
| Field | Type | Description |
|---|---|---|
state | "expanded" | "collapsed" | Derived from open; the drawer always reads expanded. Mirrors data-state |
open | boolean | Desktop open state |
setOpen | (open: boolean) => void | Set desktop open state (also writes the cookie) |
openMobile | boolean | Mobile drawer open state |
setOpenMobile | (open: boolean) => void | Set mobile drawer open state |
isMobile | boolean | True below mobileBreakpoint |
width | string | Expanded width, including a --sidebar-width override |
toggleSidebar | () => void | Toggle the right surface (desktop vs. mobile drawer) |