Sidebar
App chrome with a workspace header, nav groups, and a user footer. It collapses to an icon rail and becomes a Sheet, a push drawer, or a tray drawer on a phone.
Sidebar is the navigation rail of an app. It has a header, a scrolling content area, and a
footer, it collapses to a column of icons, and ⌘B or Ctrl+B toggles it from anywhere on
the page. On a phone it becomes a Sheet, or a drawer that
the page shoulders over, with an optional bottom tab bar and top header. Rows use
bg-surface, a bg-fill hover, and a bg-selected active fill with a 2px primary stroke on
the left, its label in text-fg and its icon in text-primary, so the rail
follows every look without a color tier of its own.
Wash: a flat selected fill behind the stroke.
The preview collapses to icons. Press the toggle or ⌘B to try it, and switch the rows
between Wash and Fade to compare them.
Usage
import { Tray } from '@phosphor-icons/react';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar';
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>{/* workspace */}</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive tooltip="Inbox" count={3}>
<Tray />
<span>Inbox</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>{/* user */}</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<SidebarTrigger />
{children}
</SidebarInset>
</SidebarProvider>SidebarProvider holds the open state and the shortcut, Sidebar is the rail, and
SidebarInset is the page beside it. SidebarTrigger is a ghost Button
that toggles the rail, and SidebarRail is a thin hit target on the rail's edge that does
the same. The page frame around the rail, a sunken well tray or a flat inset, is
App shell, and the workspace switcher for the header is
Tenant picker.
Collapsing to icons
collapsible takes "offcanvas" (the default), "icon", or "none". With "icon" the rail
narrows to a 3rem column of icons, and with "offcanvas" it slides off the edge entirely.
"none" is a fixed, always open panel with no toggle, and it stays in the page on a phone
instead of becoming a Sheet. side puts the rail on the "left" or "right", and variant
sets the panel as "sidebar" (the default, with a border), "floating" (a bordered, rounded
card), or "inset", where the page becomes a rounded card beside the rail.
The panel and every menu button share one width tween on --duration-base and --ease-out,
so icons stay put and fills keep their corners. Labels leave first and arrive last. They fade
on --duration-fast with no delay on the way out, and on the way in the fade waits so it ends
with the width, which means text never clips mid-tween. Under reduced motion the rail jumps.
When the rail is collapsed on desktop, group labels are aria-hidden and fade out. Pass
tooltip on a menu button so a sighted user can read the name of an icon, and keep the
leading element of a size="lg" row at 24px (size-6) so it centers over the icon column.
⌘B (or Ctrl+B) is ignored while focus is in an input, a textarea, a select, or an
editable region, and when something upstream already handled the event, so an editor's bold
shortcut does not collapse the rail. On a phone the same shortcut and SidebarTrigger toggle
the mobile presentation instead.
Every change to the open state writes a sidebar_state cookie that lasts seven days. The
kit only writes it and never reads it back, so to keep the state across visits, read the
cookie on the server and pass the result as defaultOpen.
Fade
variant="fade" on SidebarMenuButton swaps the flat washes for a gradient. Hover runs
fill to transparent, and the active row runs selected, held for the first third, to
transparent. Both fade away from the stroke: to the right while expanded, and upward from the
bottom stroke when collapsed. The other variants are "default" and "outline", and size
is "default" (32px), "sm" (28px), or "lg" (48px).
<SidebarMenuButton variant="fade" isActive tooltip="Inbox">
<Tray />
<span>Inbox</span>
</SidebarMenuButton>Collapsed, the stroke moves to the bottom edge of the active square and the square lifts on
shadow-sm. The stroke is an inset shadow, so the row's content does not shift.
Counts and nested groups
count puts a number at the trailing edge of a SidebarMenuButton, inside the button, so an
asChild link stays one target. It is rendered as a <data> element, and it fades with the
label when the rail collapses. SidebarMenuBadge is still there for a wrapped button that
needs an overlay, and SidebarMenuAction places a small action button at the row's end.
<SidebarMenuButton asChild isActive tooltip="Inbox" count={3}>
<Link href="/inbox">
<Tray />
<span>Inbox</span>
</Link>
</SidebarMenuButton>For a nested group, wrap a SidebarMenuButton in a Collapsible
and put the children in SidebarMenuSub. The sub list is hidden when the rail is collapsed
to icons. It draws a guide line by default, and guide={false} removes it, which suits a
borderless well where the line reads as a stroke.
A count on a row, and a group that opens onto a sub list.
Mobile
A phone is not a narrow desktop. A rail is a poor thumb target, and a drawer that only ever
overlays reads as a web page. Sidebar reads the viewport with useIsMobile (under 768px), and
from there the phone presentation is one prop, mobile on SidebarProvider, with a
bottom tab bar and a top header you can add to any of them. All of it runs on the
openMobile state the Sheet already uses, so one SidebarTrigger drives every variant.
Switch the preview between Push and Well.
Push: the page shoulders over flat, edge to edge — tab bar and all.
mobile takes "sheet" (the default), "push", or "well". The Sheet slides over the page
and is a focus-trapped dialog. The other two are drawers that the page shoulders over, covered
below.
Tab bar
SidebarTabBar holds the destinations you reach all day, at the bottom where the thumb is. It
renders only when the sidebar is in mobile mode and returns null on desktop, where the rail
is already the nav. Write it last inside SidebarInset in every variant, and the variant
decides where it ends up. In sheet and push it stays in the page and sticks to its bottom,
so a push carries it along the way a native drawer moves the whole screen. In the
well it leaves the page for the tray.
<SidebarInset>
{children}
<SidebarTabBar>
<SidebarTabBarItem icon={House} label="Home" isActive />
<SidebarTabBarItem asChild icon={ChatCircleDots} label="DMs" count={3}>
<Link href="/dms" />
</SidebarTabBarItem>
<SidebarTabBarItem icon={Bell} label="Activity" dot />
<SidebarTabBarTrigger />
</SidebarTabBar>
</SidebarInset>Tabs stack an icon over a label and share the bar's width equally. count puts a bubble on
the icon (a count of 0 shows nothing), and dot shows an unread mark with no number and a
visually hidden "Unread". A Phosphor icon switches to weight="fill" while the tab is
active, so you pass one icon and not two. Each tab is at least 56px tall, and the bar pads
itself past the home indicator with env(safe-area-inset-bottom).
SidebarTabBarTrigger is the tab that opens the rail ("More" by default). It lights up while
the rail is open and reports aria-expanded and no aria-current, because it discloses the
rail and does not navigate. A tab's current prop sets what isActive means to assistive
technology, "page" by default, or false for a tab that discloses.
Header
SidebarTrayHeader is the tab bar's counterpart at the top of the screen, for a title, a
workspace picker, or a search button. Like the tab bar, it renders only in mobile mode and
returns null on desktop. Write it first inside SidebarInset, and the variant decides where
it ends up. In sheet and push it sticks to the top of the page on bg-surface, with a rule
under it, and pads itself below the notch with env(safe-area-inset-top), so a push carries it
along with the page. In the well it leaves the page for the tray above the card.
<SidebarInset>
<SidebarTrayHeader>
<TenantPickerTrigger tenant={tenant} onClick={openAccount} />
</SidebarTrayHeader>
{children}
<SidebarTabBar>{/* tabs */}</SidebarTabBar>
</SidebarInset>The header is at least 48px tall and lays its children out in a row with an 8px gap. It has no content of its own, so render it only on the pages that need one, such as a home screen.
Push
mobile="push" swaps the Sheet for a drawer that sits still at the left edge while the page
shoulders over it.
<SidebarProvider mobile="push">
<Sidebar>{/* nav */}</Sidebar>
<SidebarInset>{/* page */}</SidebarInset>
</SidebarProvider>The page is the moving part, so the whole screen, header and content and tab bar, travels
together on --duration-base. It carries a scrim that dims with the offset. Tap the scrim to
close, or drag it left with a finger or a pen and the page follows, with a flick faster than
0.5px/ms beating the halfway line. A mouse only taps. The drag decides its axis in the first
8px, so a vertical scroll is never taken from the page.
The page goes inert while the drawer is open, so taps and Tab stay in the drawer, and
Escape closes it. Focus moves into the drawer on open and returns to whatever opened it,
which the Sheet gets from Radix and push does by hand. Document scroll locks only when the
drawer is pinned to the viewport, so a contained shell (a device frame, or
App shell's absolute rail) leaves the page behind it
alone.
Push is left-edge only, because the page can only be shouldered one way and it reads a single
offset, so a side="right" rail keeps the Sheet. The drawer's width and the page's offset
both come from --sidebar-width-mobile on the wrapper (18rem), so they are always the same
number. The Sheet is portaled out of the wrapper and cannot read it, so it stays at 18rem.
Well
mobile="well" is the same drawer in App shell's tray. It is the phone axis, and it is
separate from the rail's variant and from App shell's page-frame variant.
<SidebarProvider mobile="well">{/* ... */}</SidebarProvider>
// or the matched pair, one tray at every width
<AppShell variant="well" mobile="well" sidebar={rail}>{children}</AppShell>Four things change.
The room. The provider paints the tray: full viewport height, overflow-hidden,
bg-surface-sunken, and a gutter from --sidebar-tray (0.375rem). That is the same tray
App shell's well paints, so composing the two gives one tray and not two fighting ones. On a
phone there is no page frame to put it on, because the viewport is the room, so the well
works with or without App shell. The notch and home indicator are cleared with padding added
after your className, so a p-* shorthand cannot drop them.
The page is a card. SidebarInset becomes a rounded-xl bg-surface-raised card with
shadow-sm at rest and shadow-lg while open. It scales back 6% with the offset, so it
tucks and does not just slide, and dragging it home grows it back under your finger.
The tab bar and the header move to the tray. The tab bar sits straight on the tray with no
container, no fill, and a gutter below the card, and it stays put while the card tucks away. It
can go bare because nothing scrolls under it. The room is a column to make space for it, and
the drawer stops at the bar and does not run behind it, so a rail footer stays reachable.
SidebarTrayHeader does the same above the card, with no fill and a gutter under it, and the
drawer opens below it, so the header stays in view while the drawer is out. You still write the
bar last inside SidebarInset, and the header first. Each portals onto its tray band on its
own, because a card that transforms is a containing block and anything left inside would travel
with it.
Two things follow from the bar outliving the card. It is still live while the drawer is open,
so tapping a destination closes the drawer on its way, and the More trigger is exempt
because it is the thing that opened it. The drawer also drops aria-modal, because something
outside it is reachable. Escape, the focus move, and the focus return are unchanged.
The card is the scroll container. It is exactly the viewport's height and scrolls inside, like App shell's well stage. Page content that scrolled the document on a flat shell needs a scrolling element here.
Closing, and what the server cannot know
Closing on select is yours, because not every row navigates and the kit does not guess.
const { setOpenMobile } = useSidebar();
<SidebarMenuButton onClick={() => setOpenMobile(false)}>...The viewport is unknown on the server, so the tab bar and the drawer arrive on hydration.
Pass isMobile to SidebarProvider when the shell already knows, such as a device frame, a
test, or a layout that decides per container. isSidebarPushNav(mobile) answers "is this a
drawer the page shoulders over" if you branch on it yourself.
Order
Common actions come first, then personal shortcuts, then structure. A Views group (inbox, my issues, board) sits at the top, Pinned follows, and the entities themselves (projects, teams) come last. Readers scan from the top, and the routes they open all day belong above the ones they set up once.
Shell frame and peek
Peek overlays live on the stage. Put SidebarTrigger on the shell frame, outside the stage,
at z-40 so a peek cannot cover it. On a phone the same trigger opens whatever
Mobile is set to.
import { AppShell } from '@/components/ui/app-shell';
import {
Sidebar,
SidebarContent,
SidebarTrigger,
} from '@/components/ui/sidebar';
<AppShell
variant="well"
sidebar={
<Sidebar collapsible="icon">
<SidebarContent>{/* nav */}</SidebarContent>
</Sidebar>
}
frame={<SidebarTrigger className="absolute top-3 left-3 z-40" />}
>
{/* stage: peek overlays stack here, under the trigger */}
</AppShell>frame renders on data-slot="app-shell-frame" (relative), as a sibling of the stage. The
push drawer mounts on the same data-slot="sidebar-container" as the desktop rail, so the
shell's containment reaches it too. Without App shell, compose the same way with
SidebarProvider and AppShellStage. Do not put the trigger inside SidebarInset if a peek
will cover the stage.
Props
SidebarProvider takes every <div> attribute, plus these.
| Prop | Type | Default | What it does |
|---|---|---|---|
defaultOpen | boolean | true | Whether the desktop rail starts expanded. |
open / onOpenChange | boolean / (open) => void | Controls the desktop open state. The cookie is still written. | |
mobile | "sheet" | "push" | "well" | "sheet" | How the rail presents on a phone. |
isMobile | boolean | From the viewport | Overrides viewport detection, under 768px. |
Sidebar takes every <div> attribute, plus these.
| Prop | Type | Default | What it does |
|---|---|---|---|
collapsible | "offcanvas" | "icon" | "none" | "offcanvas" | Slides off the edge, narrows to icons, or never collapses. |
side | "left" | "right" | "left" | The edge the rail sits on. Push and well need "left". |
variant | "sidebar" | "floating" | "inset" | "sidebar" | A bordered panel, a floating card, or a rail beside an inset page. |
SidebarMenuButton takes every <button> attribute, plus these.
| Prop | Type | Default | What it does |
|---|---|---|---|
isActive | boolean | false | Marks the row as the current one with the fill and stroke. |
variant | "default" | "outline" | "fade" | "default" | Flat wash, an outlined row, or a gradient that fades away from the stroke. |
size | "default" | "sm" | "lg" | "default" | 32, 28, or 48px tall. |
tooltip | string | TooltipContent props | A tooltip on the right, shown only while the rail is collapsed on desktop. | |
count | number | string | A number at the trailing edge, inside the button. | |
asChild | boolean | false | Renders its one child, such as a link, with the row's styling. |
SidebarMenuSub takes guide (default true), and SidebarMenuSubButton takes isActive,
size ("sm" or "md"), and asChild. SidebarMenuSkeleton takes showIcon and gives a
loading row. SidebarTabBarItem takes icon, label, isActive, current, count, dot,
and asChild. SidebarTrayHeader takes every <header> attribute. useSidebar() returns
state, open, setOpen, openMobile, setOpenMobile, isMobile, mobile, and
toggleSidebar, and in the well tray and trayTop, the two tray bands the tab bar and the
header portal into, which are null in every other variant. It throws outside a provider.
Accessibility
- On desktop the rail is a
<nav>landmark named "Sidebar", and anaria-labelonSidebarrenames it. The menus inside are real lists (ulandli).SidebarInsetrenders<main>. SidebarTriggerandSidebarRailare both named "Toggle Sidebar", and the trigger reportsaria-expandedfor the rail, or for the phone drawer when that is showing. The trigger is a Button and reaches 44px on a touch screen. The rail is out of the tab order (tabIndex={-1}) and hidden below thesmbreakpoint, so the trigger and the shortcut are the keyboard paths.⌘BandCtrl+Btoggle the rail from anywhere except an input, a textarea, a select, or an editable region.- Collapsed, a menu button keeps its text as its name, because the label fades and is not
removed.
tooltipshows the name on hover and focus for sighted users, and it is hidden when the rail is expanded and on a phone. Group labels arearia-hiddenwhile collapsed. isActiveonSidebarMenuButtonandSidebarMenuSubButtonsetsaria-current="page"as well as the styling, and it reaches anasChildlink. ASidebarTabBarItemsets it when active too, andSidebarTabBarTriggersetsaria-expandedinstead.- On a phone the Sheet is a Radix dialog named "Sidebar" and described as "Application navigation", with no visible close button. Focus is trapped inside while it is open, Escape and a click outside close it, and focus returns to the trigger.
- The push drawer is a
role="dialog"named "Sidebar" that isinertwhile closed. While open it takes focus, closes on Escape, hands focus back to what opened it, and setsaria-modalwhile the page behind it isinert. The scrim is a button named "Close sidebar". In the well the tab bar stays reachable, soaria-modalis omitted. SidebarTabBaris a<nav>labelled "Primary". Adotcarries a visually hidden "Unread".SidebarTrayHeaderis a<header>. In the well it sits on the tray outside the page's<main>, where it is the page'sbannerlandmark. Insheetandpushit is insideSidebarInset's<main>, where a<header>has no landmark role. Name the controls in it as you would anywhere, since it adds no label of its own.- Rows, tabs, and the rail show a 2px focus ring on keyboard focus.
SidebarMenuActionandSidebarGroupActionreach a 44px hit area on a touch screen at any width. - Under reduced motion the width tween, the label fade, and the push and tuck movements all jump.
When to use something else
- Tabs when the choices swap panels inside one page, and Tab switcher for a short in-page mode switch with no panels. Sidebar is for destinations across the app that stay on screen.
- Breadcrumb to show where the current page sits inside a hierarchy. It complements the rail, and it does not replace it.
- App shell for the frame around the rail, the room and the stage. Sidebar keeps the rail, collapse, and phone behavior, and App shell sets the page chrome.
- Tenant picker for the workspace switcher in the rail's header.
Install
shadcn add @gesso/sidebar