Gesso
Navigation

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.

Issues⌘B
Collapse the rail or press ⌘B.

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.

Collapse to hide the count

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.

#general
JS

Joe Ship the tab bar behind a flag first.

MC

Maya Drag the page back over the drawer.

JS

Joe Or tap the dimmed page to close it.

MC

Maya Counts ride the icon, not the label.

JS

Joe The rail is still the desktop nav.

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.

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.

PropTypeDefaultWhat it does
defaultOpenbooleantrueWhether the desktop rail starts expanded.
open / onOpenChangeboolean / (open) => voidControls the desktop open state. The cookie is still written.
mobile"sheet" | "push" | "well""sheet"How the rail presents on a phone.
isMobilebooleanFrom the viewportOverrides viewport detection, under 768px.

Sidebar takes every <div> attribute, plus these.

PropTypeDefaultWhat 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.

PropTypeDefaultWhat it does
isActivebooleanfalseMarks 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.
tooltipstring | TooltipContent propsA tooltip on the right, shown only while the rail is collapsed on desktop.
countnumber | stringA number at the trailing edge, inside the button.
asChildbooleanfalseRenders 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 an aria-label on Sidebar renames it. The menus inside are real lists (ul and li). SidebarInset renders <main>.
  • SidebarTrigger and SidebarRail are both named "Toggle Sidebar", and the trigger reports aria-expanded for 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 the sm breakpoint, so the trigger and the shortcut are the keyboard paths.
  • ⌘B and Ctrl+B toggle 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. tooltip shows the name on hover and focus for sighted users, and it is hidden when the rail is expanded and on a phone. Group labels are aria-hidden while collapsed.
  • isActive on SidebarMenuButton and SidebarMenuSubButton sets aria-current="page" as well as the styling, and it reaches an asChild link. A SidebarTabBarItem sets it when active too, and SidebarTabBarTrigger sets aria-expanded instead.
  • 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 is inert while closed. While open it takes focus, closes on Escape, hands focus back to what opened it, and sets aria-modal while the page behind it is inert. The scrim is a button named "Close sidebar". In the well the tab bar stays reachable, so aria-modal is omitted.
  • SidebarTabBar is a <nav> labelled "Primary". A dot carries a visually hidden "Unread".
  • SidebarTrayHeader is a <header>. In the well it sits on the tray outside the page's <main>, where it is the page's banner landmark. In sheet and push it is inside SidebarInset'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. SidebarMenuAction and SidebarGroupAction reach 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

On this page