Documentation
Navigation

Sidebar

An app shell's navigation column, recombined from the menu family, Clickable, Badge, Tooltip and Drawer instead of rebuilt from scratch.

npx shadcn@latest add @fragiola/sidebar
Sidebar
Gallery

Composition

shadcn's sidebar rebuilds a menu item, a group label, an icon button and a sub-list of its own: 28 .cn-sidebar-* classes (111 lines in each of its 8 styles), 8 --sidebar-* tokens, and 3 !importants to square its buttons in icon mode. Here every part points at a piece the library already has, and the sidebar adds no stylesheet, no token and no !important. The only new style is two named members on the menu family.

PartSource
MenuButton / MenuSubButtonmenu.navItem / menu.navSubItem through useRender
GroupLabelmenu.label
Trigger / GroupAction / MenuActionClickable.Button (icon fill, square form)
MenuBadgeBadge
MenuSkeletonSkeleton
SeparatorSeparator
Inputa Field row + Input
icon-mode tooltipsTooltip, on the inline-end side
mobileDrawer

A navigation item is the menu family's row pointing at pages instead of commands. The only difference is how it is lit. A dropdown item is lit by focus, which follows the pointer inside a menu. A navigation item is lit by hover, by isActive (the current page, data-active) and by a focus-visible ring: a clicked link keeps focus, and lit by focus it would read as a second current page. So the two share a skeleton and differ in one line, rather than the sidebar owning a second item.

Layout model

The sidebar is laid out against its Provider, not the viewport. The Provider's wrapper is a size container: below 42rem of wrapper width (Tailwind's @2xl) the sidebar becomes a Drawer behind the Trigger, and above it, the column is shown. The desktop column is sticky inside the wrapper, not fixed to the viewport.

It behaves the same full-page, in an iframe (this page's example is one) and in any box you put it in. The container variant decides the first paint, so a server-rendered page shows the right mode before JavaScript runs.

sticky needs nothing between the Provider and the scrolling element to clip with overflow: hidden (use overflow: clip). The column is h-svh tall, which fits a page that scrolls the viewport. If the sidebar sits under a fixed header, or the box that scrolls is not the viewport, give the Root its height through className:

<Sidebar.Root className="top-12 h-[calc(100svh-3rem)]">

A Provider in a fixed-height box takes the box's height, and the column stops there:

<div className="h-96">
    <Sidebar.Provider className="h-full min-h-0">…</Sidebar.Provider>
</div>

Collapse modes and variants

collapsible is offcanvas (the column slides away, and what it holds leaves the tab order and the accessibility tree), icon (it narrows to an icon rail, where labels, badges, actions, sub-menus and the search field step aside and each MenuButton's tooltip appears) or none (always shown, never a Drawer). variant is sidebar (flush, with an edge line), floating (a card with a gutter) or inset (the page becomes a card on the sidebar's floor).

Palette

The sidebar is palette-raised; the page beside it (Sidebar.Inset) is palette-surface. To swap the sidebar's palette, put a surface-tier palette class on the Root. It reaches the mobile Drawer too, which is portalled and would not inherit it:

<Sidebar.Root className="palette-surface-blue">

Use a surface-tier palette (surface, raised, surface-*), not a chromatic one. The items are the menu family's, whose resting text is secondary text (accent at 85%), guaranteed readable on neutral surfaces only, the same limit a dropdown menu has. With variant="inset", the floor around the page card is the Provider's palette: pass the same class to the Provider.

Persistence

The sidebar stores nothing: no cookie, no storage. It takes defaultOpen, or open and onOpenChange to be controlled, and the app persists where its stack reads the value back. With a server that renders the first paint, a cookie:

// On the server: read the cookie into the first render.
const defaultOpen = cookies().get("sidebar_state")?.value !== "false";

// In the client component: write it back on every change.
<Sidebar.Provider
    defaultOpen={defaultOpen}
    onOpenChange={(open) => {
        document.cookie = `sidebar_state=${open}; path=/; max-age=604800`;
    }}
>

Without one, localStorage:

const [open, setOpen] = useState(
    () => localStorage.getItem("sidebar_state") !== "false",
);
useEffect(() => localStorage.setItem("sidebar_state", String(open)), [open]);

<Sidebar.Provider open={open} onOpenChange={setOpen}>

onOpenChange fires for the desktop column only. The mobile Drawer's state is separate and is not meant to persist.

Collapsible groups and sub-menus

Base UI's Collapsible gives the behaviour, the sidebar part is its trigger through render, and Collapsible.Panel brings the height animation:

import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";

<CollapsiblePrimitive.Root defaultOpen render={<Sidebar.MenuItem />}>
    <CollapsiblePrimitive.Trigger render={<Sidebar.MenuButton />}>
        <BookOpenIcon />
        <span>Documentation</span>
        <ChevronRightIcon className="ms-auto transition-transform rtl:-scale-x-100 in-data-panel-open:rotate-90 rtl:in-data-panel-open:-rotate-90" />
    </CollapsiblePrimitive.Trigger>
    <Collapsible.Panel>
        <Sidebar.MenuSub>…</Sidebar.MenuSub>
    </Collapsible.Panel>
</CollapsiblePrimitive.Root>

The styled Collapsible.Root and Collapsible.Trigger are not used here. They draw a bordered disclosure row, and the MenuButton already is the row, so wearing them would mean cancelling their classes. A collapsible group works the same way with Sidebar.GroupLabel as the trigger.

RTL

side is start or end, not left or right: a start sidebar is on the right in Arabic, its Drawer opens from the right, its edge line, sub-menu line, badges and actions all flip, and icon-mode tooltips open on the inline-end side. The Drawer reads the direction from Base UI's DirectionProvider.

Keyboard

Ctrl+B (⌘+B on macOS) toggles the sidebar, as in shadcn, except while focus is in a field, a select or an editable region (where it is the field's: bold, in a rich-text editor) or when another handler has already taken the key. Every Provider on the page listens. The Rail is out of the tab order; the Trigger is the keyboard path.

Parts

PartSource
Providerstate, the shortcut, the size-container wrapper
Rootthe sticky column (desktop) or Drawer (mobile)
TriggerClickable.Button + PanelLeftIcon
Raila hit area on the column's inner edge
Insetthe page, palette-surface
Header / Content / Footer / Group / GroupContentlayout
GroupLabelmenu.label
GroupAction / MenuActionClickable.Button
Menu / MenuItem / MenuSub / MenuSubItemul / li
MenuButton / MenuSubButtonmenu.navItem / menu.navSubItem
MenuBadgeBadge
MenuSkeletonSkeleton
SeparatorSeparator
InputField row + Input
useSidebar()state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar