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 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.
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:
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):
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.
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:
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:
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:
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:
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:
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:
Navigation anatomy
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.
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:
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.
Persisting to a cookie (SSR)
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:
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.Navrenders a<nav>landmark namedMainby default; overridearia-labelwhen your app has multiple navigation landmarks (breadcrumbs, pagination) or for localization.Sidebar.Triggerfollows the disclosure pattern:aria-expandedreflects the state andaria-controlsreferences 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+Btoggles the sidebar. The modifier is resolved per platform (⌘on Apple platforms,Ctrleverywhere else), and the two never substitute for each other, so macOS's nativeCtrl+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 anycontenteditablehost, which covers embedded editors like Monaco and CodeMirror. Opt out entirely withkeyboardShortcut={false}onSidebar.Root.- When the shell owns the document, pair the sidebar with a
SkipToMainLink.AppLayout.Bodyis themainlandmark 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.ItemButtoncurrentrendersaria-current="page", and the row's current treatment is styled from that attribute as well asdata-current, so the announced state and the visual state cannot drift apart, whichever side sets it.Sidebar.GroupLabelis linked to itsSidebar.Listviaaria-labelledby.- The mobile sheet is a modal dialog with focus trapping and
Escapedismissal, courtesy of Sheet. Its accessible name follows the nav's ("Main"by default), so overridingaria-labeloraria-labelledbyonSidebar.Navrenames the dialog too. - The collapse/expand transitions respect
prefers-reduced-motion.
API Reference
Sidebar.Root
The state owner. Renders no DOM.
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:
Sidebar.Nav takes no asChild — see Polymorphism.
Data attributes
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:
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
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.
All props from div, plus:
Sidebar.Body
The scrollable middle region of Sidebar.Nav, holding the navigation groups.
All props from div, plus:
Sidebar.Footer
The pinned structural container at the bottom of Sidebar.Nav — the account/user switcher row lives here.
All props from div, plus:
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:
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:
Sidebar.List
The ul of navigation rows inside a group.
All props from ul, plus:
Sidebar.Item
A single navigation row — an li wrapping a Sidebar.ItemButton.
All props from li, plus:
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:
Data attributes
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:
Data attributes
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:
Data attributes
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:
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
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:
Data attributes
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:
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.
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.
SIDEBAR_STATE_COOKIE_NAME
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.