Sidebar

Composable, collapsible application sidebar — header, grouped navigation, and a footer.

Main content area

import { SidebarHeroDemo } from "@/demos/sidebar-demos";  export function Page() {    return <SidebarHeroDemo />;  }

Usage

Import the primitives from @reva/ui and compose your app shell. The same parts build everything from a chat assistant rail to a sectioned settings nav — these demos live in the docs app (apps/docs/src/demos/sidebar-demos.tsx), not in @reva/ui.

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarLogo,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarUserMenu,
} from "@reva/ui";

Wrap the app in SidebarProvider, place a Sidebar (set collapsible="icon" for the rail collapse) beside a SidebarInset for the main content. SidebarLogo takes a mark (symbol) and a wordmark; the symbol stays put on collapse while only the wordmark fades, and it renders the toggle and the collapsed logo↔toggle hover-swap. Press ⌘/Ctrl+B to toggle. Drag the rail's outer edge to resize it (double-click the edge to reset); the width persists.

Responsive

Below the lg breakpoint (1024px) the sidebar stays present as the collapsed 64px icon rail rather than disappearing — the icon nav is tappable and navigates directly. Tapping the logo mark, tapping the rail's outer edge, or pressing ⌘/Ctrl+B expands it in place, exactly like desktop — the panel widens and the main content reflows beside it. No extra trigger to wire — the rail and its expand behaviour come for free from the same composition.

Composition

Use the following composition to build a Sidebar:

SidebarProvider
├── Sidebar (collapsible="icon")
│   ├── SidebarHeader
│   │   └── SidebarLogo (mark, wordmark)
│   ├── SidebarContent
│   │   └── SidebarGroup
│   │       ├── SidebarGroupLabel        (optional)
│   │       ├── SidebarGroupAction       (optional)
│   │       └── SidebarGroupContent
│   │           └── SidebarMenu
│   │               └── SidebarMenuItem
│   │                   ├── SidebarMenuButton (isActive, size, tooltip)
│   │                   └── SidebarMenuAction (optional, trailing — wrap in DropdownMenu)
│   └── SidebarFooter
│       └── SidebarUserMenu (name, secondary?, avatarSrc?, avatarFallback)
│           └── DropdownMenuItem · DropdownMenuSeparator · DropdownMenuLabel  (children)
└── SidebarInset                          (main content)

User menu

SidebarUserMenu is the footer identity row — avatar, name, an optional secondary line, and a caret that opens a dropdown. It owns the trigger geometry, the avatar size, and the collapse/alignment behaviour, so it stays identical across apps. The menu items are app-specific: pass DropdownMenuItem / DropdownMenuSeparator / DropdownMenuLabel as children, using asChild for router or external links.

<SidebarFooter>
  <SidebarUserMenu
    name="James Smith"
    secondary="Wonder Consulting Ltd"
    avatarFallback="JS"
  >
    <DropdownMenuLabel>Account</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem>
      <Gear />
      Settings
    </DropdownMenuItem>
    <DropdownMenuItem variant="destructive">
      <SignOut />
      Log out
    </DropdownMenuItem>
  </SidebarUserMenu>
</SidebarFooter>

Omit secondary to render the name alone. Pass avatarSrc for a photo (it falls back to avatarFallback).

Examples

Minimal

A few items, no groups, no footer — the smallest viable sidebar.

Main content area

import { SidebarMinimalDemo } from "@/demos/sidebar-demos";  export function Page() {    return <SidebarMinimalDemo />;  }

Sectioned

Multiple labelled groups and a footer — SidebarGroupLabel / SidebarGroupAction and longer sectioned navigation.

Main content area

import { SidebarSectionedDemo } from "@/demos/sidebar-demos";  export function Page() {    return <SidebarSectionedDemo />;  }

Scrollable content

When the navigation outgrows the panel, SidebarContent scrolls on its own — it's a ScrollArea (type="hover", so the scrollbar reveals on hover) — while SidebarHeader and SidebarFooter stay pinned. No extra wiring: the header and footer are shrink-0 and the content is flex-1 min-h-0, so only the menu groups move.

Main content area

import { SidebarScrollableDemo } from "@/demos/sidebar-demos";  export function Page() {    return <SidebarScrollableDemo />;  }

Docked

variant="docked" drops the floating 8px inset: the rail sits flush to the viewport edges with square corners and full height. Everything else — resize, collapse, min/max width, motion — is identical to the default floating variant.

Main content area

import { SidebarDockedDemo } from "@/demos/sidebar-demos";  export function Page() {    return <SidebarDockedDemo />;  }

Nested navigation

SidebarMenuStack turns the menu region into a drill-down. Pass a tree of SidebarNavNodes: a node with children drills in — the menu swoops left to reveal its children, led by a back row. onSelect fires on click regardless of children, so a parent can navigate to its own route (e.g. /business-a) and open its submenu in one click; a leaf node just fires onSelect. The back row navigates too — it re-fires the onSelect of the section it returns to, so routing works both directions with no extra config. Nesting is arbitrary depth. It sits in place of SidebarContent, so the header and footer stay fixed; only the menu list slides (instant under prefers-reduced-motion), and keyboard focus follows the swoop into the newly-shown level. When the rail is collapsed to icons, the root level's icon rows show — click still navigates, but drilling is disabled (the drilled level returns when the rail expands).

Navigated to /business-a/cashflow

import { SidebarMenuStack, type SidebarNavNode } from "@reva/ui";  const items: SidebarNavNode[] = [    {      id: "business-a",      label: "Business A",      icon: Buildings,      // A parent navigates AND drills in the same click.      onSelect: () => router.push("/business-a"),      children: [        { id: "treasury", label: "Treasury", onSelect: () => router.push("/business-a/treasury") },        { id: "cashflow", label: "Cashflow", onSelect: () => router.push("/business-a/cashflow") },        { id: "dividends", label: "Dividends", onSelect: () => router.push("/business-a/dividends") },      ],    },    // …Business B, Business C  ];  export function Page() {    return (      <Sidebar collapsible="icon">        <SidebarHeader>…</SidebarHeader>        <SidebarMenuStack items={items} />        <SidebarFooter>…</SidebarFooter>      </Sidebar>    );  }

Accessibility

  • SidebarMenuButton sets aria-current="page" when isActive.
  • SidebarTrigger is labelled Toggle Sidebar; collapsed items surface their label via a Tooltip.
  • The collapse animation honours prefers-reduced-motion (instant).

Props

PropTypeDefaultDescription
side"left" | "right""left"Which edge the rail sits on.
collapsible"icon" | "offcanvas" | "none""icon"Collapse behaviour. icon = rail; offcanvas = slide away; none = static.
variant"floating" | "docked""floating"floating sits in an 8px inset with rounded corners; docked is flush to the viewport edges (no inset, square, full height).
PropTypeDescription
markReactNodeSymbol — persistent across collapse; centers in the rail and crossfades to the toggle on hover when collapsed.
wordmarkReactNodeLogotype shown beside the symbol when expanded; fades out and collapses its width on collapse.

SidebarMenuButton

PropTypeDefaultDescription
isActivebooleanfalseApplies the brand-amber active treatment.
size"default" | "sm""default"default = 40px icon row; sm = 32px row (icon-less lists).
tooltipstring | TooltipContentPropsShown when collapsed.
asChildbooleanfalseRender as a child (e.g. an <a> / router Link).

SidebarMenuAction

An xs ghost IconButton pinned to a menu item's trailing edge — wrap it in a DropdownMenu (via DropdownMenuTrigger asChild) for row actions. Place it as a sibling of SidebarMenuButton inside SidebarMenuItem.

PropTypeDefaultDescription
showOnHoverbooleantrueReveal only on row hover/focus (and while its menu is open). false keeps it always visible.
asChildbooleanfalseRender as a child (e.g. a DropdownMenuTrigger).

SidebarMenuStack

The drill-down nav. Place it in place of SidebarContent (between SidebarHeader and SidebarFooter).

PropTypeDefaultDescription
itemsSidebarNavNode[]The root-level nav tree. Treated as stable (seeds the stack once).
defaultPathstring[][]Initial drilled-into path, as node ids from the root.
onPathChange(path: string[]) => voidFired on drill in / back, with the new array of node ids.

Each SidebarNavNode is { id, label, icon?, isActive?, children?, onSelect? }. A node with children is drillable (a trailing caret shows); onSelect fires on click for parents and leaves alike — so a parent both navigates and drills.

On this page