Skip to main content

Sidebar

A composable, collapsible app-navigation sidebar. Sidebar.Root owns the state and renders no DOM; Sidebar.Nav renders the panel — inline on desktop, collapsing to a skinny icon rail, and a left-side Sheet below the root's mobileBreakpoint; and Sidebar.Trigger toggles it from anywhere under the root, which is what keeps the sidebar decoupled from your app shell: place the trigger in an App Layout header without the shell knowing the sidebar exists.

The sidebar is routing-agnostic. Compose router links with asChild on Sidebar.ItemButton and drive current from your router's location. The demo below runs in its own framed document, exactly like a real app: switch the preview to the mobile viewport to see the sheet presentation, use the trigger in the header to collapse and expand the panel, open the product switcher at the top of the sidebar to choose a product from a centered Dialog, and toggle the notice to see the shell's full-window AppLayout.Notice strip push everything (sidebar included) down.

Sidebar.Footer holds the panel's stable rows, bottom-anchored under the scrolling body: the account-scoped destinations every product shares, a Help row, a Sidebar.Separator, and the account switcher. The Help row and the switcher row are ordinary sidebar rows composed with DropdownMenu.Trigger asChild, so each keeps the panel's row treatment, stays highlighted while its menu is open, and opens the menu upward (side="top"); the footer sits at the bottom of the viewport, so downward is off-screen. The switcher's one menu covers both scopes it names: account destinations and switching, then user-level items including a Theme row that flips light and dark. That row is mantle's ThemeDropdownMenuItem: one row, so it fits the menu beside the collapsed icon rail.

Choosing Account settings from that menu shows a second section of navigation in the same sidebar: the header's switcher becomes a back link, Sidebar.Body renders the settings groups, and the <nav> landmark renames itself from Main to Settings. That is composition, not a feature: the sidebar never knows which section it is showing; the app derives it from the route and renders different children. The footer does not move while that happens. Click into the frame and press ⌘B/Ctrl+B; the shortcut binds to the frame's own window, not this page. The demo narrows mobileBreakpoint to md so the desktop panel survives the preview's desktop and tablet widths; ngrok's dashboards keep the lg default.

Composition

Compose the parts of a Sidebar together to build your own:

Sidebar.Root├── Sidebar.Nav│   ├── Sidebar.Header│   │   └── Sidebar.SwitcherTrigger│   ├── Sidebar.Body│   │   ├── Sidebar.SearchTrigger│   │   └── Sidebar.Group│   │       ├── Sidebar.GroupLabel│   │       └── Sidebar.List│   │           └── Sidebar.Item│   │               └── Sidebar.Tooltip│   │                   └── Sidebar.ItemButton│   └── Sidebar.Footer│       ├── Sidebar.ItemButton│       ├── Sidebar.Separator│       └── Sidebar.SwitcherTrigger└── Sidebar.Trigger

Sidebar.Root renders no DOM: it only carries state, so Sidebar.Trigger can live anywhere under it, including inside layout components that know nothing about the sidebar. Render exactly one Sidebar.Nav per Sidebar.Root; nested roots shadow the outer sidebar for everything below them. There is deliberately no account-switcher part: a "Switch accounts" submenu is a composition of DropdownMenu's RadioGroup/RadioItem with Avatar, exactly as the shell demo above shows: the account model (what a row contains, badges, ordering) belongs to the app, not the design system.

Polymorphism

Sidebar.Header, Sidebar.Body, Sidebar.Footer, Sidebar.Group, Sidebar.GroupLabel, Sidebar.List, Sidebar.Item, Sidebar.ItemButton, Sidebar.SearchTrigger, Sidebar.SwitcherTrigger, and Sidebar.Separator accept an asChild prop. When true, the part renders its single child instead of its default element, forwarding all class names, data-* attributes, and the ref onto that child. The most common uses are router links on Sidebar.ItemButton (shown throughout this page) and composing Sidebar.SwitcherTrigger with DropdownMenu.Trigger asChild or Dialog.Trigger asChild. Reach for it on Sidebar.SearchTrigger to put the search row's styling on your own element. With asChild the row renders the single slotted child, so shortcut is not accepted: you render any chord hint inside your own element. Sidebar.Separator passes it to the Separator it composes, and requires exactly one child to clone.

Sidebar.Root, Sidebar.Nav, Sidebar.Trigger, and Sidebar.Tooltip are the exceptions. Sidebar.Root renders no DOM at all, so there is nothing to swap; the nav renders a fixed two-element collapse structure (and a Sheet on mobile); the trigger renders a fully-wired control (build custom triggers with useSidebar instead); and the tooltip's surface is mantle's Tooltip.Content, which omits asChild because its arrow is a permanent sibling of the body. The row's own polymorphism goes through the Sidebar.ItemButton or Sidebar.SwitcherTrigger inside it.

Below, Sidebar.GroupLabel renders a real <h3> and each Sidebar.ItemButton renders an <a> instead of its default <button>. The parts' classes, data-* state, and refs land on your elements, and the group's aria-labelledby wiring still names the list. Inspect the rendered markup to see it.

import { Sidebar } from "@ngrok/mantle/sidebar";import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";import { GraphIcon } from "@phosphor-icons/react/Graph";import { useState } from "react"; export function TrafficNav() {	// this example fakes a router; a real app renders react-router `Link`s	// through `asChild` and reads `useLocation()`	const [pathname, setPathname] = useState("/endpoints"); 	return (		<div className="w-64 text-sm">			<Sidebar.Group>				<Sidebar.GroupLabel asChild>					<h3>Traffic</h3>				</Sidebar.GroupLabel>				<Sidebar.List>					<Sidebar.Item>						<Sidebar.ItemButton asChild current={pathname === "/endpoints"}>							<a								href="/endpoints"								onClick={(event) => {									event.preventDefault();									setPathname("/endpoints");								}}							>								<GraphIcon />								Endpoints							</a>						</Sidebar.ItemButton>					</Sidebar.Item>					<Sidebar.Item>						<Sidebar.ItemButton asChild current={pathname === "/domains"}>							<a								href="/domains"								onClick={(event) => {									event.preventDefault();									setPathname("/domains");								}}							>								<GlobeHemisphereWestIcon />								Domains							</a>						</Sidebar.ItemButton>					</Sidebar.Item>				</Sidebar.List>			</Sidebar.Group>		</div>	);}

With react-router

The example above fakes a router so this page stays self-contained. With react-router, render a NavLink and let it own the active state. This is the whole integration.

The two snippets in this section are the only ones on this page with nothing running above them, and deliberately so: this documentation site is itself a react-router app, react-router permits exactly one router per tree, and a live NavLink here would resolve against the docs site's own URLs and navigate the reader off the page on click. That is also why every other example on this page fakes a router. Both snippets are complete components; copy either one straight into a real app, where its NavLinks resolve against your routes:

import { Sidebar } from "@ngrok/mantle/sidebar";import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";import { GraphIcon } from "@phosphor-icons/react/Graph";import { NavLink } from "react-router"; export function TrafficNavLinks() {	return (		<Sidebar.Group>			<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>			<Sidebar.List>				<Sidebar.Item>					<Sidebar.ItemButton asChild>						<NavLink to="/endpoints">							<GraphIcon />							Endpoints						</NavLink>					</Sidebar.ItemButton>				</Sidebar.Item>				<Sidebar.Item>					<Sidebar.ItemButton asChild>						<NavLink to="/domains">							<GlobeHemisphereWestIcon />							Domains						</NavLink>					</Sidebar.ItemButton>				</Sidebar.Item>			</Sidebar.List>		</Sidebar.Group>	);}

No current, no useMatch, no className: the row highlights itself. NavLink already resolved the match and sets aria-current="page", and the current-row treatment follows either aria-current="page" or data-current, so the router's own answer drives mantle's own styling. Pass current for the rows whose parent knows (a plain Link, a custom matcher, a row that is current for a reason the URL doesn't express).

Reach for NavLink's render-prop className only to add styling of your own on top; Sidebar.ItemButton's asChild composes it rather than dropping it, at the usual child-wins precedence (see Slot → Render props on the child):

import { Sidebar } from "@ngrok/mantle/sidebar";import { GraphIcon } from "@phosphor-icons/react/Graph";import { NavLink } from "react-router"; export function PendingAwareRow() {	return (		<Sidebar.Item>			<Sidebar.ItemButton asChild>				<NavLink					className={({ isPending }) => (isPending ? "animate-pulse" : undefined)}					to="/endpoints"				>					<GraphIcon />					Endpoints				</NavLink>			</Sidebar.ItemButton>		</Sidebar.Item>	);}

Width

The panel's width is a CSS variable, not a prop: Sidebar.Nav reads each of the three widths with the default as its fallback (w-(--sidebar-width,13rem)), so there is nothing to thread through your shell and one place to change it.

CSS VariableDefaultWhat it sizes
--sidebar-width13rem (208px)The expanded desktop panel
--sidebar-width-icon3.25rem (52px)The collapsed icon rail
--sidebar-width-mobile18rem (288px)The mobile Sheet panel

Override one sidebar by setting the variable on Sidebar.Nav: a Tailwind arbitrary property is the shortest route, and cx keeps it deterministic. The panel below is 18rem wide expanded; collapse it with the trigger and the rail still uses the default --sidebar-width-icon:

import { Sidebar } from "@ngrok/mantle/sidebar";import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";import { GraphIcon } from "@phosphor-icons/react/Graph"; export function WideSidebar() {	return (		// `sm` rather than the `lg` default keeps this inline panel visible at tablet		// widths; a real app shell keeps the default		<Sidebar.Root mobileBreakpoint="sm">			{/* the panel fills its ancestor's height — a real app shell gets that from					`AppLayout.Workspace`, so an inline example brings its own frame */}			<div className="border-card-muted bg-base flex h-72 w-full max-w-lg overflow-hidden rounded-lg border">				<Sidebar.Nav aria-label="Main" className="[--sidebar-width:18rem]">					<Sidebar.Body>						<Sidebar.Group>							<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>							<Sidebar.List>								<Sidebar.Item>									<Sidebar.ItemButton current>										<GraphIcon />										Endpoints									</Sidebar.ItemButton>								</Sidebar.Item>								<Sidebar.Item>									<Sidebar.ItemButton>										<GlobeHemisphereWestIcon />										Domains									</Sidebar.ItemButton>								</Sidebar.Item>							</Sidebar.List>						</Sidebar.Group>					</Sidebar.Body>				</Sidebar.Nav>				<div className="flex-1 p-3">					<Sidebar.Trigger />				</div>			</div>		</Sidebar.Root>	);}

Override every sidebar in your app from your global stylesheet. Custom properties inherit, and the parts read them with a fallback, so any ancestor of Sidebar.Nav can set them:

:root {	--sidebar-width: 18rem;	--sidebar-width-mobile: 20rem;}

Three consequences of that inheritance direction worth knowing: setting a width on something the panel contains does nothing (values flow parent → child, never child → parent); AppLayout never needs to know the number: the AppLayout.Content card takes the remaining row space, so a wider sidebar narrows the card by construction; and a surface that renders outside the panel (a switcher's menu, portaled to document.body) inherits none of them, which is what --sidebar-row-width exists for.

Collapsing to the icon rail

On desktop the panel collapses to a skinny icon rail that keeps every row's leading icon visible and clickable: each row becomes a square chip around its icon, group labels fade out in place (their rows are retained, so icon groups stay visually separated and every icon keeps the exact position it has when expanded), the switcher rows in the header and footer collapse to their leading visual (product icon, account avatar), and the rail keeps the same bg-base surface as the expanded panel: no border, no separate chrome. The rail width is the --sidebar-width-icon CSS variable (default 3.25rem); see Width.

The rail, its rows, the switcher rows, the separators, and the body's trailing padding move on one 200ms ease-out clock. As the rail narrows, a row trades its full-width shape for the chip, so nothing snaps to its new shape on the first frame. A second toggle mid-flight reverses from the point the first one reached. Under prefers-reduced-motion, the whole move is a cut. Every transition holds until one frame after hydration, so a persisted state the client corrects snaps into place on page load.

Everything in the icon rail stays in the tab order and the accessibility tree (labels are clipped or visually hidden, not removed), so screen readers announce the full row names and lists keep their group names. The scrollable Sidebar.Body always fades its overflowing edges; in the collapsed rail it also hides its scrollbar and relies on the fade alone. Collapse and expand the shell demo at the top of this page with its header trigger or ⌘B/Ctrl+B (click into the frame first; the shortcut binds to the frame's own window).

Labeling the rail for pointer users

Keeping labels in the accessibility tree serves screen-reader users, but a sighted pointer user hovering the collapsed rail sees an unlabeled icon column. Wrap a row in Sidebar.Tooltip to give them the label back. The panel below starts collapsed; hover either icon to see its label, and use the trigger to expand it:

import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { Sidebar } from "@ngrok/mantle/sidebar";import { BookOpenIcon } from "@phosphor-icons/react/BookOpen";import { CaretDownIcon } from "@phosphor-icons/react/CaretDown";import { GraphIcon } from "@phosphor-icons/react/Graph";import { QuestionIcon } from "@phosphor-icons/react/Question"; export function LabeledRail() {	return (		// the rows' tooltips need a `TooltipProvider` ancestor, mounted at your app root		<Sidebar.Root defaultOpen={false} mobileBreakpoint="sm">			<div className="border-card-muted bg-base flex h-72 w-full max-w-lg overflow-hidden rounded-lg border">				<Sidebar.Nav aria-label="Main">					<Sidebar.Body>						<Sidebar.Group>							<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>							<Sidebar.List>								<Sidebar.Item>									<Sidebar.Tooltip label="Endpoints">										<Sidebar.ItemButton current>											<GraphIcon />											Endpoints										</Sidebar.ItemButton>									</Sidebar.Tooltip>								</Sidebar.Item>							</Sidebar.List>						</Sidebar.Group>					</Sidebar.Body>					<Sidebar.Footer>						{/* a row that also opens a menu: `DropdownMenu.Root` stays OUTSIDE the								tooltip — it is renderless, and `Tooltip.Trigger asChild` needs a								real element to clone */}						<DropdownMenu.Root>							<Sidebar.Tooltip label="Help">								<DropdownMenu.Trigger asChild>									<Sidebar.ItemButton>										<QuestionIcon />										Help										<CaretDownIcon className="text-muted ml-auto size-4 shrink-0" />									</Sidebar.ItemButton>								</DropdownMenu.Trigger>							</Sidebar.Tooltip>							<DropdownMenu.Content								align="start"								side="top"								width="trigger"								className="min-w-(--sidebar-row-width)"							>								<DropdownMenu.Item className="gap-2">									<BookOpenIcon className="text-muted" />									Documentation								</DropdownMenu.Item>							</DropdownMenu.Content>						</DropdownMenu.Root>					</Sidebar.Footer>				</Sidebar.Nav>				<div className="flex-1 p-3">					<Sidebar.Trigger />				</div>			</div>		</Sidebar.Root>	);}

The label shows only while the desktop panel is collapsed: expanded rows already read their own text and the mobile sheet shows full labels, so there is nothing to duplicate. The rail state vetoes the tooltip's open state rather than withholding its content: the expanded panel and the mobile sheet keep the row at data-state="closed" with no aria-describedby, and toggling the rail dismisses whatever was showing, so collapsing the panel cannot pop a label under a pointer that has already moved on. Tooltip.Root itself stays mounted at every rail state, which keeps the row (and its focus) through a collapse.

It needs a TooltipProvider ancestor, like any Tooltip.Root: mount one at your app root. The part deliberately does not mount its own, so your app-wide tooltip delay still applies. The one provider setting it does override is hoverable content: a rail label holds nothing to hover into, so the label closes when the pointer leaves the row instead of waiting for the pointer to leave the label too.

For a row that also opens a menu (the Help row above), keep DropdownMenu.Root outside the tooltip: it is renderless, and Tooltip.Trigger asChild needs a real element to clone. The menu itself takes the --sidebar-row-width floor, so it keeps the expanded row's width in the rail (see Sizing a menu to a sidebar row).

Sizing a menu to a sidebar row

A menu opened from a sidebar row should be as wide as the row it opens from, and DropdownMenu.Content's width="trigger" does exactly that, until the panel collapses to the icon rail, where the trigger is a 36px chip (28px for a Sidebar.ItemButton row) and the menu shrinks with it. The menu cannot solve this from the sidebar's variables either: it renders in a portal on document.body, so it inherits nothing the panel sets.

mantle.css declares the row width at :root for this, derived from the panel width:

CSS VariableDefaultDescription
--sidebar-row-width12remOne row's width inside the expanded panel (192px): calc(var(--sidebar-width, 13rem) - 1rem), the panel width minus the gutter Sidebar.Header/Body/Footer all apply.

Keep width="trigger" and floor the menu at it. The switcher row below is a collapsed chip; open its menu, expand the panel with the trigger, and open it again; the menu is the same width both times:

import { Avatar } from "@ngrok/mantle/avatar";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { Sidebar } from "@ngrok/mantle/sidebar";import { GearIcon } from "@phosphor-icons/react/Gear";import { GraphIcon } from "@phosphor-icons/react/Graph";import { SignOutIcon } from "@phosphor-icons/react/SignOut";import { UserIcon } from "@phosphor-icons/react/User"; export function AccountRowMenu() {	return (		<Sidebar.Root defaultOpen={false} mobileBreakpoint="sm">			<div className="border-card-muted bg-base flex h-72 w-full max-w-lg overflow-hidden rounded-lg border">				<Sidebar.Nav aria-label="Main">					<Sidebar.Body>						<Sidebar.Group>							<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>							<Sidebar.List>								<Sidebar.Item>									<Sidebar.ItemButton current>										<GraphIcon />										Endpoints									</Sidebar.ItemButton>								</Sidebar.Item>							</Sidebar.List>						</Sidebar.Group>					</Sidebar.Body>					<Sidebar.Footer>						<DropdownMenu.Root>							<DropdownMenu.Trigger asChild>								<Sidebar.SwitcherTrigger>									<Avatar.Root appearance="square" colorSeed="acc_acme">										<Avatar.Fallback name="Acme Corp" />									</Avatar.Root>									<span className="text-strong min-w-0 flex-1 truncate text-sm font-medium">										Acme Corp									</span>									<Avatar.Root aria-label="Jane Doe" className="text-muted" role="img">										<Avatar.Fallback>											<UserIcon className="size-4" />										</Avatar.Fallback>									</Avatar.Root>								</Sidebar.SwitcherTrigger>							</DropdownMenu.Trigger>							<DropdownMenu.Content								align="start"								side="top"								width="trigger"								className="min-w-(--sidebar-row-width)"							>								<DropdownMenu.Item className="gap-2">									<GearIcon className="text-muted" />									Account settings								</DropdownMenu.Item>								<DropdownMenu.Item className="gap-2">									<SignOutIcon className="text-muted" />									Log out								</DropdownMenu.Item>							</DropdownMenu.Content>						</DropdownMenu.Root>					</Sidebar.Footer>				</Sidebar.Nav>				<div className="flex-1 p-3">					<Sidebar.Trigger />				</div>			</div>		</Sidebar.Root>	);}

One static class covers all three presentations at the default widths. Expanded, the floor is a no-op: the row already measures that wide, so the menu is flush with it. Collapsed, the floor holds the menu at the expanded row's width, so it looks identical in the rail. In the mobile Sheet the floor is inert because the sheet's row is the wider of the two (--sidebar-width-mobile − 1.5rem − the sheet's 1px border-r = 263px, against 192px), so width="trigger" wins.

That last one is a condition, not a guarantee: widen --sidebar-width-mobile whenever you widen --sidebar-width, or the floor outgrows the sheet's row and the menu overhangs the panel it opened from (--sidebar-width: 22rem alone gives a 336px menu inside a 288px sheet).

The same asymmetry applies to a per-sidebar override. Because the token resolves at :root, a --sidebar-width set on a single Sidebar.Nav does not reach it in either direction: a narrower panel leaves the floor wider than its row. Override --sidebar-width at :root (see Width) to move the panel and its menus together, or declare the token on the menu content for a one-off, which works because a custom property is read from the element that declares it. The panel below is 12rem wide and its Help menu carries a matching floor, so the menu stays flush with the row instead of overhanging it:

import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { Sidebar } from "@ngrok/mantle/sidebar";import { BookOpenIcon } from "@phosphor-icons/react/BookOpen";import { CaretDownIcon } from "@phosphor-icons/react/CaretDown";import { GraphIcon } from "@phosphor-icons/react/Graph";import { QuestionIcon } from "@phosphor-icons/react/Question"; export function NarrowSidebarMenu() {	return (		<Sidebar.Root mobileBreakpoint="sm">			<div className="border-card-muted bg-base flex h-72 w-full max-w-lg overflow-hidden rounded-lg border">				<Sidebar.Nav aria-label="Main" className="[--sidebar-width:12rem]">					<Sidebar.Body>						<Sidebar.Group>							<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>							<Sidebar.List>								<Sidebar.Item>									<Sidebar.ItemButton current>										<GraphIcon />										Endpoints									</Sidebar.ItemButton>								</Sidebar.Item>							</Sidebar.List>						</Sidebar.Group>					</Sidebar.Body>					<Sidebar.Footer>						<DropdownMenu.Root>							<DropdownMenu.Trigger asChild>								<Sidebar.ItemButton>									<QuestionIcon />									Help									<CaretDownIcon className="text-muted ml-auto size-4 shrink-0" />								</Sidebar.ItemButton>							</DropdownMenu.Trigger>							{/* the panel-scoped `--sidebar-width` cannot reach the `:root` token,									so the menu declares the floor its own row actually measures */}							<DropdownMenu.Content								align="start"								side="top"								width="trigger"								className="[--sidebar-row-width:calc(12rem-1rem)] min-w-(--sidebar-row-width)"							>								<DropdownMenu.Item className="gap-2">									<BookOpenIcon className="text-muted" />									Documentation								</DropdownMenu.Item>							</DropdownMenu.Content>						</DropdownMenu.Root>					</Sidebar.Footer>				</Sidebar.Nav>				<div className="flex-1 p-3">					<Sidebar.Trigger />				</div>			</div>		</Sidebar.Root>	);}

The navigation parts are plain styled elements: Sidebar.Group pairs an optional muted Sidebar.GroupLabel with a Sidebar.List of rows, and links the two with aria-labelledby automatically so each list is announced with its group's name. Sidebar.ItemButton renders a leading icon slot and a truncating label, and current sets both aria-current="page" and the data-current visual state: the row's current treatment follows either one, so a composed child that marks itself (react-router's NavLink) needs no current at all.

important

The navigation is one level deep. There is no nested item and no collapsible group: a Sidebar.List holds rows, not sub-lists. A two-level nav cannot be lifted and shifted onto this component; it has to flatten into Sidebar.GroupLabel + rows, which also means every row that was previously a second-level text link now needs a leading icon (the collapsed rail is icon-only, so a row without one becomes a blank chip). Budget for that before you start: it is usually the largest single chunk of a migration, and it is a design decision, not a mechanical one.This is deliberate: a disclosure tree is a different ARIA pattern with its own keyboard contract, and nesting it inside a rail that collapses to icons has no coherent collapsed presentation. If you genuinely need a tree, compose one from Accordion inside Sidebar.Body rather than expecting the rail to collapse it.
import { Sidebar } from "@ngrok/mantle/sidebar";import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";import { HashIcon } from "@phosphor-icons/react/Hash";import { useState } from "react"; export function NetworkNav() {	// this example fakes a router; a real app renders react-router `Link`s	// through `asChild` and reads `useLocation()`	const [pathname, setPathname] = useState("/domains"); 	return (		<div className="w-64 text-sm">			<Sidebar.Group>				<Sidebar.GroupLabel>Network</Sidebar.GroupLabel>				<Sidebar.List>					<Sidebar.Item>						<Sidebar.ItemButton asChild current={pathname === "/domains"}>							<a								href="/domains"								onClick={(event) => {									event.preventDefault();									setPathname("/domains");								}}							>								<GlobeHemisphereWestIcon />								Domains							</a>						</Sidebar.ItemButton>					</Sidebar.Item>					<Sidebar.Item>						<Sidebar.ItemButton asChild current={pathname === "/tcp-addresses"}>							<a								href="/tcp-addresses"								onClick={(event) => {									event.preventDefault();									setPathname("/tcp-addresses");								}}							>								<HashIcon />								TCP Addresses							</a>						</Sidebar.ItemButton>					</Sidebar.Item>				</Sidebar.List>			</Sidebar.Group>		</div>	);}

The search row

Sidebar.SearchTrigger is the row that opens a search or command palette. It is deliberately a navigation row and not a text field: the chrome is a Sidebar.ItemButton's, down to the 28px chip it becomes in the collapsed rail, because a search entry point in a sidebar is one of the rows rather than a form control wedged among them. Its shortcut hint appears on hover or keyboard focus and is otherwise invisible, so the resting panel stays quiet.

The row is styling only. Wrap it in Command.SearchTrigger for the dialog wiring, aria-keyshortcuts, and the behavior that makes typing or pasting into the row open the palette with that text already in the query; and in Sidebar.Tooltip so the row keeps a visible label once the panel collapses.

Sidebar.Body is the default home: above the first Sidebar.Group, in the same column as the navigation rows. Put the row in Sidebar.Header instead when it has to stay put while the navigation scrolls: the switcher keeps the header's alignment band, the search row takes its own row below, and the AppLayout.Header toolbar keeps matching the switcher row alone. The header reserves no scrollbar gutter, so on a platform whose scrollbars take space a row there runs wider than the rows inside Sidebar.Body.

Collapse the panel to watch the row join the icon column, or press ⌘K inside the preview.

Put it above the first Sidebar.Group so it shares the column the navigation rows use in both panel states, and mark it up like any other row: a leading icon, then the label in an element of its own so the rail can clip it without dropping it from the button's accessible name.

Persisting the collapsed state

Sidebar.Root supports both uncontrolled (defaultOpen) and controlled (open + onOpenChange) desktop state, and the mobile sheet mirrors that with openMobile + onOpenMobileChange. To persist the collapsed state across visits, use the fully controlled form with useLocalStorage:

important

With storage-backed persistence in an SSR app, defaultOpen does not work: the uncontrolled state initializes exactly once at the hydration render, before the stored value is available, so controlled open is the reliable form. This does not apply to the cookie recipe below: a cookie is available to the server, so defaultOpen is correct there.

This recipe is SSR-safe: useLocalStorage is useSyncExternalStore-backed with a server snapshot, so the server render and the hydration render agree (no hydration mismatch), and the sidebar suppresses its collapse transition until one frame after hydration (through useIsHydratedAfterPaint), so a persisted-collapsed sidebar snaps shut instead of animating on page load. What no localStorage recipe can do is affect the server-rendered HTML itself: a persisted-collapsed sidebar paints expanded for one frame before the post-hydration correction. If that frame matters, persist to a cookie instead, the same way the theme system solves FOUC server-side.

Toggle the sidebar in the demo, then reload the preview with the toolbar's reload button: the collapsed state is restored from localStorage.

A cookie is the only storage the server can read, so it is the only way to render the collapsed state into the initial HTML with no first-frame correction at all. @ngrok/mantle/sidebar ships the pair for it, mirroring extractThemeCookie.

This is the one recipe on this page with no live preview above it, because the thing being demonstrated is a server render: the value has to arrive from a loader, in the initial HTML, before any of this page's JavaScript runs. Here is the whole route module, a react-router 7 root, complete as written:

// app/root.tsximport { AppLayout } from "@ngrok/mantle/app-layout";import {	extractSidebarStateCookie,	serializeSidebarStateCookie,	Sidebar,} from "@ngrok/mantle/sidebar";import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";import { GraphIcon } from "@phosphor-icons/react/Graph";import { NavLink, Outlet } from "react-router";import type { Route } from "./+types/root"; export const loader = ({ request }: Route.LoaderArgs) => ({	// `undefined` means "no preference yet" — pick your own default here	sidebarOpen: extractSidebarStateCookie(request.headers.get("Cookie")) ?? true,}); export default function Root({ loaderData }: Route.ComponentProps) {	return (		<Sidebar.Root			defaultOpen={loaderData.sidebarOpen}			onOpenChange={(open) => {				document.cookie = serializeSidebarStateCookie(open);			}}		>			<AppLayout.Root className="fixed inset-0">				<SkipToMainLink />				<AppLayout.Workspace>					<Sidebar.Nav aria-label="Main">						<Sidebar.Body>							<Sidebar.Group>								<Sidebar.GroupLabel>Traffic</Sidebar.GroupLabel>								<Sidebar.List>									<Sidebar.Item>										<Sidebar.ItemButton asChild>											<NavLink to="/endpoints">												<GraphIcon />												Endpoints											</NavLink>										</Sidebar.ItemButton>									</Sidebar.Item>								</Sidebar.List>							</Sidebar.Group>						</Sidebar.Body>					</Sidebar.Nav>					<AppLayout.Content>						{/* each route renders AppLayout.Header and AppLayout.Body */}						<Outlet />					</AppLayout.Content>				</AppLayout.Workspace>			</AppLayout.Root>		</Sidebar.Root>	);}

defaultOpen is correct here, not controlled open. The value is already available at the server render and the hydration render, so the uncontrolled state initializes to the right value once and stays there; the server HTML ships data-state="collapsed" directly, with zero flash.

extractSidebarStateCookie returns boolean | undefined rather than boolean on purpose: a first-time visitor with no cookie is not the same as someone who deliberately collapsed the rail, and folding the two together would collapse the sidebar for everyone's first visit. Choose the default explicitly with ?? true.

serializeSidebarStateCookie returns a plain cookie string, valid for both document.cookie on the client and a Set-Cookie header on the server, so it works whichever side of your app owns the write. Secure is omitted by default so the same call persists over http://localhost; if your policy requires it, pass { secure: true }. There is no HttpOnly option: the browser has to write this cookie when the user toggles the rail.

Accessibility

  • Sidebar.Nav renders a <nav> landmark named Main by default; override aria-label when your app has multiple navigation landmarks (breadcrumbs, pagination) or for localization.
  • Sidebar.Trigger follows the disclosure pattern: aria-expanded reflects the state and aria-controls references the nav. Keep a trigger visible at every breakpoint where the sidebar can collapse: a collapsed sidebar with no visible trigger is unrecoverable.
  • ⌘B / Ctrl+B toggles the sidebar. The modifier is resolved per platform (⌘ on Apple platforms, Ctrl everywhere else), and the two never substitute for each other, so macOS's native Ctrl+B ("move the caret back one character", which every macOS text field binds) is left alone. The shortcut is also ignored while focus is in an <input>, <textarea>, <select>, or any contenteditable host, which covers embedded editors like Monaco and CodeMirror. Opt out entirely with keyboardShortcut={false} on Sidebar.Root.
  • When the shell owns the document, pair the sidebar with a SkipToMainLink. AppLayout.Body is the main landmark the link targets, so keyboard users jump past the sidebar's tab gauntlet straight into the scrolling page region (see App Layout → Landmarks).
  • The collapsed icon rail stays fully interactive: row labels are clipped and switcher text is visually hidden, but both remain in the tab order and the accessibility tree, so accessible names do not change between states.
  • Sidebar.ItemButton current renders aria-current="page", and the row's current treatment is styled from that attribute as well as data-current, so the announced state and the visual state cannot drift apart, whichever side sets it. Sidebar.GroupLabel is linked to its Sidebar.List via aria-labelledby.
  • The mobile sheet is a modal dialog with focus trapping and Escape dismissal, courtesy of Sheet. Its accessible name follows the nav's ("Main" by default), so overriding aria-label or aria-labelledby on Sidebar.Nav renames the dialog too.
  • The collapse/expand transitions respect prefers-reduced-motion.

API Reference

Sidebar.Root

The state owner. Renders no DOM.

PropTypeDefaultDescription
defaultOpen?booleantrueInitial desktop expanded state for the uncontrolled case.
open?boolean—Controlled desktop expanded state. Pair with onOpenChange.
onOpenChange?(open: boolean) => void—Called with the next desktop expanded state.
openMobile?boolean—Controlled mobile sheet open state. Pair with onOpenMobileChange.
onOpenMobileChange?(open: boolean) => void—Called with the next mobile sheet open state.
mobileBreakpoint?"sm" | "md" | "lg""lg"Breakpoint below which Sidebar.Nav renders as a mobile Sheet.
keyboardShortcut?booleantrueToggle with ⌘B (Apple) / Ctrl+B (elsewhere). Exact-modifier match, resolved per platform (⌘⇧B and macOS's native Ctrl+B pass through), and ignored while focus is in a text-editing or form control; set false to opt out.

Sidebar.Nav

The panel. All props from div plus the panel contract: className, style, ref, and rest props land on the panel surface in both presentations (the desktop collapse container and the mobile Sheet.Content), and aria-label (default "Main") is forwarded to the inner <nav> landmark.

All props from div, plus:

PropTypeDefaultDescription
aria-label?string"Main"Names the inner <nav> landmark, and the mobile Sheet dialog along with it. Override it for localization. Omitted when you pass aria-labelledby instead.
aria-labelledby?string—Names the <nav> from another element's id, and names the mobile sheet dialog directly. Takes precedence over the aria-label default.
data-slot?string—An ancestor-forwarded data-slot chain, joined ahead of this part's own sidebar-nav rather than replacing it. In the mobile sheet, Sheet.Content joins its own sheet-content after it.

Sidebar.Nav takes no asChild — see Polymorphism.

CSS VariableDefaultDescription
--sidebar-width13remExpanded desktop width (208px). Set it on Sidebar.Nav for one sidebar or on any ancestor (:root) for every sidebar — see Width.
--sidebar-width-mobile18remMobile sheet width (288px). Set it on Sidebar.Nav or any ancestor — see Width.
--sidebar-width-icon3.25remCollapsed icon rail width (52px). Set it on Sidebar.Nav or any ancestor — see Width.
--sidebar-row-width12remRead, not set: one row's width in the expanded panel (192px), derived as calc(var(--sidebar-width, 13rem) - 1rem) and declared at :root by mantle.css so surfaces rendered outside the panel can size to a row — see Sizing a menu to a sidebar row.

Data attributes

Data AttributeValueDescription
data-state"expanded" | "collapsed"On the desktop panel surface. Mirrors the root's expanded state and drives the width collapse to the icon rail; descendant parts style off it with group-data-[state=collapsed]/sidebar-nav:.
data-hydratedpresent one frame after hydrationPresence-only, desktop panel surface. The panel's pre-hydration visibility gate, its width transition, and descendant collapse transitions all key off its absence in CSS (not-data-hydrated:, group-not-data-hydrated/sidebar-nav:). The stamp waits one frame, so an SSR state correction paints first and snaps instead of animating on page load.
data-mobilepresent in the mobile sheetPresence-only. Marks the Sheet.Content presentation used below the root's mobileBreakpoint.
data-state"open" | "closed"In the mobile sheet only, where the panel is the Sheet's Radix dialog content element and Radix owns the attribute — the sheet's open/close animation state, not the desktop expanded state. Consumers style against it too.

Sidebar.Header reads one more public token, --sidebar-header-height, which belongs on a common ancestor of the sidebar and the app-layout header rather than on the panel.

Sidebar.Trigger

A small ghost IconButton wired to toggle the sidebar. All IconButton props except asChild and children, with sidebar defaults:

PropTypeDefaultDescription
appearance?"filled" | "ghost" | "outlined""ghost"The visual style of the trigger.
icon?ReactNode<SidebarSimpleIcon />The icon rendered inside the trigger.
intent?"neutral""neutral"The color intent of the trigger. IconButton draws the neutral tone only.
label?string"Toggle Sidebar"Accessible name, and what the tooltip says; override for localization.
shortcut?ReactNode—Chord chips rendered after the label in the tooltip — usually <><MetaKey /><Kbd>B</Kbd></>. Dropped under Sidebar.Root keyboardShortcut={false}, and aria-hidden because aria-keyshortcuts already announces the chord.
size?"xs" | "sm" | "md" | "lg" | "xl""sm"The size of the trigger. Small, so it sits level with the other size="sm" controls in an AppLayout.Header row.

The button is icon-only at every breakpoint, so it always renders a tooltip showing label. That means it requires a TooltipProvider ancestor, like any Tooltip.Root, and throws without one — mount a single provider at your app root, decoupled from the sidebar, so the app-wide delay and hover settings stay app-wide. It also stamps aria-keyshortcuts ("Meta+B" on Apple platforms, "Control+B" elsewhere), omitted along with the chips under Sidebar.Root keyboardShortcut={false}. The server cannot know the host, so the server HTML and the hydration render carry "Control+B". React corrects it once after hydration. A trigger that mounts after hydration reads the platform in its first render.

Data attributes

Data AttributeValueDescription
data-state"expanded" | "collapsed"Mirrors what the trigger toggles: the mobile sheet below the root's mobileBreakpoint, the desktop panel otherwise. Pairs with aria-expanded.
data-slot"sidebar-trigger icon-button"On the button: the part's slot, joined ahead of IconButton's own.
data-slot"sidebar-trigger-tooltip tooltip-content"On the tooltip surface, not the button: the styling hook for the label-and-chord popup this part renders, joined ahead of Tooltip.Content's own slot.
data-state"delayed-open" | "instant-open" | "closed"Read, not stamped: on the tooltip surface, from Tooltip.Content.
data-side"top" | "right" | "bottom" | "left"Read, not stamped: on the tooltip surface, from Tooltip.Content.
data-slot"sidebar-tooltip-row"Inside that surface, on the flex row holding the label and the optional chord chips. Always present.
data-slot"sidebar-tooltip-label"On the <span> wrapping label inside that row. Always present, and display: contents.
data-slot"sidebar-tooltip-shortcut"On the chord chips. Present only while shortcut holds something. Carries translate="no": a chord is a key name, not prose.
data-appearance"filled" | "ghost" | "outlined"Read, not stamped: the underlying IconButton reflects its appearance, which this part defaults to "ghost".
data-intent"neutral"Read, not stamped: the underlying IconButton reflects its intent, and it draws the neutral tone only.
data-size"xs" | "sm" | "md" | "lg" | "xl"Read, not stamped: the underlying IconButton reflects its size, which this part defaults to "sm".

Sidebar.Header

The pinned structural container at the top of Sidebar.Nav — the app/product switcher row lives here. Its first child owns a band exactly --sidebar-header-height tall (default 4.5rem) and centers inside it. When the sidebar composes into an AppLayout, AppLayout.Header derives its own height from the same token, so the two rows stay center-aligned by construction.

Every child after the first stacks below the band in its own row, and the header grows to fit. That is how a search row pins under the switcher: the band — and with it the toolbar — stays the height of the switcher row alone, and the row inherits the header's gutter, so neither the token nor the padding needs a copy. The alignment is positional, so keep the aligned row as the header's first direct child. A wrapper around both rows takes the band itself, and a first row taller than the band overflows it.

note

The alignment is a desktop-only coupling. AppLayout.Header finds the sidebar header with :has() from AppLayout.Root. Below mobileBreakpoint the sidebar renders inside a Sheet that portals to document.body — outside the shell subtree — so the selector stops matching and AppLayout.Header falls back to its standalone h-14 (56px) from the derived 54px. That 2px change lands after hydration, because the server renders desktop-first. It is invisible in practice and there is nothing to align to on mobile (the sidebar header is inside a closed sheet), but if you are diffing layout snapshots across breakpoints, that is where the 2px comes from.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Header styling onto alternative element types or your own React components.
CSS VariableDefaultDescription
--sidebar-header-height4.5remThe first child's band (72px) — the height AppLayout.Header matches, not a cap on the header, which grows for every row after the first. Set it on a common ancestor of the sidebar and the app-layout header — e.g. AppLayout.Root — not on Sidebar.Nav, so both rows read the same value and stay center-aligned.

Sidebar.Body

The scrollable middle region of Sidebar.Nav, holding the navigation groups.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Body styling onto alternative element types or your own React components.

Sidebar.Footer

The pinned structural container at the bottom of Sidebar.Nav — the account/user switcher row lives here.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Footer styling onto alternative element types or your own React components.

Sidebar.Group

A navigation grouping div that pairs an optional Sidebar.GroupLabel with a Sidebar.List. When a label is present the group links it to the list via aria-labelledby.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Group styling onto alternative element types or your own React components.

Sidebar.GroupLabel

The group's label, a div — pass asChild to render a heading at a level you control. In the collapsed icon rail it fades in place with its row retained, so icon positions match the expanded state.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.GroupLabel styling onto alternative element types or your own React components.

Sidebar.List

The ul of navigation rows inside a group.

All props from ul, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.List styling onto alternative element types or your own React components.

Sidebar.Item

A single navigation row — an li wrapping a Sidebar.ItemButton.

All props from li, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Item styling onto alternative element types or your own React components.

Sidebar.ItemButton

The interactive navigation row. Expanded spacing matches the regular sidebar; in the collapsed icon rail, item rows keep their original square icon chip with a small offset so body and footer icons stay aligned without text peeking from the rail.

The row sizes its leading icon (20px) and leaves trailing visuals — a menu caret, a count, a status pip — to size themselves, so composing one needs no ! override. A row composed with DropdownMenu.Trigger asChild also stays highlighted while its menu is open (data-state="open", supplied by the trigger), and takes the same --sidebar-row-width floor on its menu as a switcher row (see Sizing a menu to a sidebar row). That is how the Help row is built in the shell demo at the top of this page and in the live example under Labeling the rail for pointer users.

All props from button plus:

PropTypeDefaultDescription
asChild?booleanfalseRender the single child (e.g. a router link) instead of a <button>.
current?boolean—Marks the current page: sets aria-current="page" and data-current. Not needed for a child that marks itself, like react-router's NavLink.

Data attributes

Data AttributeValueDescription
data-slot"sidebar-item-button"Names the part, with any composing ancestor's chain ahead of it.
data-currentpresent when currentPresence-only, never "false", since the data-current: variant matches the attribute's existence. Styles the current row.
aria-current"page" when currentSet alongside data-current. The current-row styling follows either, so a composed child that marks itself (react-router's NavLink) gets the treatment without the prop.
data-state"open" | "closed"Read, not stamped: supplied by a composing DropdownMenu.Trigger / Dialog.Trigger. The row styles against it so it stays highlighted while its menu is open. Inside Sidebar.Tooltip the tooltip's own data-state replaces this one, so the open highlight also keys on aria-expanded.

Sidebar.SearchTrigger

The row that opens a search or command palette. Place it in Sidebar.Body above the first Sidebar.Group, so it shares the column the navigation rows use in both panel states and scrolls with them, or in Sidebar.Header when it has to stay pinned while the navigation scrolls — see The search row.

It is deliberately a navigation row and not a text field. The row is the same chrome as a Sidebar.ItemButton — same height, padding, radius, hover, and the same 28px chip in the collapsed icon rail — because a search entry point in a sidebar is one of the rows, not a form control wedged among them. Its shortcut hint appears on hover or keyboard focus and is otherwise invisible, which keeps the resting panel quiet.

The hint renders in a <span data-slot="sidebar-search-trigger-shortcut"> that is always mounted on the default button path, so a chord that resolves later appends instead of replacing the row's children. While it holds nothing it is display: none, so it costs the row no flex gap. children render in their own <span data-slot="sidebar-search-trigger-label"> beside it. That span is display: contents, so it adds no box. It keeps your own swap of a text label to an element off React's removal path. Under asChild both spans are absent, because the row renders a single slotted child there. Browser translation covers why.

The label span makes an icon you pass a grandchild of the row, so the row carries both selector forms: a direct-child one for the asChild path, and [&>[data-slot=sidebar-search-trigger-label]>svg:first-child] for the default button path. A [&>svg] override of your own still reaches an asChild icon. Add the slot-scoped twin when you override the button path.

A styled button only — compose it with Command.SearchTrigger, which supplies the dialog wiring, aria-keyshortcuts, and the behavior that makes typing or pasting into the row open the palette with that text already in the query. Wrap the pair in Sidebar.Tooltip so the row keeps a visible label in the rail.

All props from button, plus:

PropTypeDefaultDescription
shortcut?ReactNodeThe keyboard hint for the chord that opens the same palette, usually <><MetaKey /><Kbd>K</Kbd></>. Rendered pinned to the row's end, aria-hidden, revealed on hover or focus.
asChild?booleanfalseUse the asChild prop to compose the Sidebar.SearchTrigger styling onto alternative element types or your own React components.

Data attributes

Data AttributeValueDescription
data-slot"sidebar-search-trigger"Names the part, with any composing ancestor's chain ahead of it: "command-search-trigger sidebar-search-trigger" once a palette wraps it.
data-slot"sidebar-search-trigger-label"On the <span> wrapping children. Default button path only: under asChild the row renders no sibling, so it needs no wrapper. Always display: contents, so your icon and label stay flex items of the row.
data-slot"sidebar-search-trigger-shortcut"On the chord chips. Default button path only, where it is always mounted and display: none while empty; the props union forbids shortcut under asChild. Carries translate="no": a chord is a key name, not prose.
data-state"open" | "closed"Read, not stamped: supplied by the composing Command.SearchTrigger / Dialog.Trigger. The row stays highlighted while its palette is open.

The shortcut hint is dropped outright in the collapsed rail — the chip has room for the icon and nothing else, and aria-keyshortcuts already announces the chord. The row's label is clipped rather than removed, so the button keeps its accessible name as a chip.

Sidebar.SwitcherTrigger

The styled switcher row for the header (app switcher) and footer (account/user row). Its leading visual is size-7 to match selected rail icons while the row keeps a compact 36px footprint — Avatar is that size by default, which is what an account or user row puts there. The control uses a custom 10px radius on all corners so product and account switchers share the same shape around their rounded tiles. A styled button only — compose it with DropdownMenu.Trigger asChild or Dialog.Trigger asChild, which set the open state (data-state="open" styling responds automatically).

Match the trailing affordance to the surface the trigger actually opens. A CaretDown promises a menu dropping from the row, so keep it for DropdownMenu-backed switchers; a Dialog-backed switcher reads better with CaretUpDown, which says "pick one of several" without pointing anywhere.

All props from button, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.SwitcherTrigger styling onto alternative element types or your own React components.

Data attributes

Data AttributeValueDescription
data-slot"sidebar-switcher-trigger"Names the part, with any composing ancestor's chain ahead of it.
data-state"open" | "closed"Read, not stamped: supplied by the composing DropdownMenu.Trigger / Dialog.Trigger. The row styles against it so it stays highlighted while its menu is open. Inside Sidebar.Tooltip the tooltip's own data-state replaces this one, so the open highlight also keys on aria-expanded.

Inside the collapsed icon rail, the row collapses to its first child element — the leading visual — and the remaining children become visually hidden while staying in the accessibility tree, so the button's accessible name is unchanged. Wrap loose text in an element (e.g. a <span>) so it participates. Because the row shrinks, give the menu it opens the --sidebar-row-width floor (className="min-w-(--sidebar-row-width)") so the menu keeps the expanded row's width — see Sizing a menu to a sidebar row.

Sidebar.Tooltip

Labels a row while — and only while — the desktop panel is collapsed to the icon rail. See Labeling the rail for pointer users. Requires a TooltipProvider ancestor and must be inside a Sidebar.Root (it throws otherwise).

It opens on hover or focus like any tooltip and closes when the pointer leaves the row — the label itself is not hoverable. The rail state vetoes it: the expanded panel and the mobile sheet never show it, and toggling the rail dismisses whatever was showing, so collapsing the panel cannot pop a label under a pointer that has already moved on.

Its props are Tooltip.Content's, minus the two this row owns, plus:

PropTypeDefaultDescription
childrenReactElement—The single element row to label: a Sidebar.ItemButton or Sidebar.SwitcherTrigger, optionally already wrapped in a DropdownMenu.Trigger asChild. Required — Tooltip.Trigger asChild clones it, so text or no child renders nothing hoverable and is a compile error.
labelReactNode—What the tooltip says — normally the row's own label text.
shortcut?ReactNode—Chord chips rendered after the label — usually <><MetaKey /><Kbd>K</Kbd></>. The rail hides the row's own hint along with its text, so the tooltip is where a pointer user can still learn it. aria-hidden, since the row's aria-keyshortcuts already announces it.
side?"top" | "right" | "bottom" | "left""right"Which side of the row the tooltip opens on. The rail sits at the inline start of the viewport, so the default points the tooltip away from it.
className?string—Merged onto the tooltip surface, last, by Tooltip.Content — this part adds no classes of its own.
data-slot?string—An ancestor-forwarded data-slot chain, joined ahead of this part's own sidebar-tooltip rather than replacing it.

asChild is not accepted. Tooltip.Content omits it because its arrow is a permanent sibling of the body, so a slot would receive two elements. See Polymorphism.

Every remaining Tooltip.Content prop — style, ref, align, sideOffset, collisionPadding, any data-* — lands on the tooltip surface as usual, and so do the attributes Tooltip itself emits there (data-state, data-side). The four in the table above are the exceptions: this part reads them itself.

Data attributes

Data AttributeValueDescription
data-slot"sidebar-tooltip tooltip-content"On the tooltip surface, with any forwarded chain ahead of it and Tooltip.Content's own slot after it. Styling and test-targeting hook.
data-state"delayed-open" | "instant-open" | "closed"Read, not stamped: on the tooltip surface, from Tooltip.Content.
data-side"top" | "right" | "bottom" | "left"Read, not stamped: on the tooltip surface, from Tooltip.Content. The resolved side, "right" by default.
data-align"start" | "center" | "end"Read, not stamped: on the tooltip surface, from Tooltip.Content.
data-slot"sidebar-tooltip-row"Inside that surface, on the flex row holding the label and the optional chord chips.
data-slot"sidebar-tooltip-label"On the <span> wrapping label inside that row. Always present, and display: contents.
data-slot"sidebar-tooltip-shortcut"On the chord chips. Present only while shortcut holds something. Carries translate="no": a chord is a key name, not prose.
data-state"delayed-open" | "instant-open" | "closed"On the wrapped row, from Tooltip.Trigger. "closed" outside the collapsed rail, where this part vetoes the open state.

That row is always mounted, so a shortcut that resolves later appends beside an element instead of replacing the label. The label span is always mounted too, so your own swap of label from text to an element never aims a removal at a text node the engine reparented. The row nests inside Tooltip.Content's own tooltip-label div: sidebar-tooltip tooltip-content → tooltip-label (display: contents) → sidebar-tooltip-row (the flex row) → sidebar-tooltip-label (display: contents). Browser translation covers why.

Sidebar.Separator

An inset hairline between sidebar regions, aligned with the surrounding Sidebar.Body and Sidebar.Footer padding (it deliberately does not run edge to edge). When the sidebar is expanded, those regions trim their trailing inset to account for the adjacent app-content gutter. When it collapses to the icon rail, the separator widens to the collapsed switcher chip width so it stays optically centered between the viewport edge and content card.

All props from Separator, including:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the Sidebar.Separator styling onto alternative element types or your own components. Requires children to clone (SelfClosingWithAsChild), so <Sidebar.Separator asChild /> with no child is a compile error.
orientation?"horizontal" | "vertical""horizontal"The separator's axis. Sidebar regions stack vertically, so the horizontal default is almost always what you want.
semantic?booleanfalseWhen true, renders role="separator" and is announced. Leave it false for a decorative divider between navigation groups.

Data attributes

Data AttributeValueDescription
data-orientation"horizontal" | "vertical"Read, not stamped: Separator reflects its orientation prop, "horizontal" by default, after a HorizontalSeparatorGroup ancestor wins.
data-separatorpresentRead, not stamped: presence-only, from Separator. Lets a HorizontalSeparatorGroup exempt it from shrink-0.

useSidebar

A hook returning the nearest Sidebar.Root state (SidebarState) — throws outside a root. Use it for custom triggers, close-on-navigate, or a keyboard shortcut:

FieldTypeDescription
openbooleanDesktop expanded state.
setOpen(open: boolean) => voidSet the desktop expanded state. Keeps one identity across renders, so it is safe in an effect dependency list.
openMobilebooleanMobile sheet open state.
setOpenMobile(open: boolean) => voidSet the mobile sheet state (e.g. close on navigation). Keeps one identity across renders, so it is safe in an effect dependency list.
isMobilebooleanWhether the viewport is below mobileBreakpoint.
mobileBreakpoint"sm" | "md" | "lg"The configured mobile breakpoint.
toggle() => voidToggle whichever presentation is active.
navIdstringThe nav landmark's id, for custom triggers' aria-controls.
keyboardShortcutbooleanWhether this root binds ⌘B / Ctrl+B. Read it in a custom trigger so a visible chord hint can never name a binding that is not bound.

extractSidebarStateCookie

Parses the sidebar's expanded state out of a raw Cookie header, for cookie-backed persistence. Framework-agnostic — it takes a header string, so it works in any server runtime.

ParamTypeDescription
cookieHeaderstring | null | undefinedThe request's raw Cookie header.
returnsboolean | undefinedtrue expanded, false collapsed, undefined when unset or unparseable — decide your own default with ?? true.

serializeSidebarStateCookie

Serializes the expanded state as a cookie string, valid for both document.cookie and a Set-Cookie header. No HttpOnly option: the browser writes this cookie from onOpenChange.

ParamTypeDefaultDescription
openboolean—Whether the sidebar is expanded.
options?SerializeSidebarStateCookieOptions{}Cookie attributes; see below.
returnsstring—e.g. mantle-sidebar-state=collapsed; Max-Age=31536000; Path=/; SameSite=Lax
OptionTypeDefaultDescription
maxAge?number31536000Lifetime in seconds (one year).
path?string"/"Path the cookie applies to.
domain?string—Omitted by default (exact host). Set e.g. ".example.com" to share across subdomains.
sameSite?"Lax" | "Strict" | "None""Lax"SameSite policy.
secure?booleanfalseOmitted by default so the cookie is not rejected over http://localhost.

The cookie name the two helpers read and write ("mantle-sidebar-state"), exported so an app can clear it on sign-out or read it with its own cookie library.