Sidebar
The app rail: workspace switcher, search, grouped navigation and the user menu.
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { Kbd } from "@oration/canon/components/kbd";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarProvider,} from "@oration/canon/components/sidebar";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { Building2Icon, CalendarClockIcon, ChevronsUpDownIcon, FileTextIcon, HouseIcon, InboxIcon, ListChecksIcon, type LucideIcon, ReceiptIcon, SearchIcon, SettingsIcon, ShieldCheckIcon,} from "lucide-react";import * as React from "react";export function Hero() { type Row = { id: string; label: string; icon: LucideIcon; count?: number }; const groups: { label?: string; items: Row[] }[] = [ { items: [ { id: "home", label: "Home", icon: HouseIcon }, { id: "inbox", label: "Inbox", icon: InboxIcon, count: 4 }, { id: "approvals", label: "Approvals", icon: ListChecksIcon, count: 12, }, ], }, { label: "Payables", items: [ { id: "invoices", label: "Invoices", icon: FileTextIcon }, { id: "payment-runs", label: "Payment runs", icon: CalendarClockIcon, }, { id: "suppliers", label: "Suppliers", icon: Building2Icon }, { id: "remittances", label: "Remittances", icon: ReceiptIcon }, ], }, { label: "Compliance", items: [ { id: "w9s", label: "W-9s", icon: ShieldCheckIcon, count: 3 }, ], }, ]; const [active, setActive] = React.useState("invoices"); const current = groups .flatMap((group) => group.items) .find((item) => item.id === active); return ( <div className="relative flex h-[30rem] w-full max-w-3xl overflow-hidden rounded-xl bg-background shadow-border"> <SidebarProvider className="h-full min-h-0"> <Sidebar collapsible="none" accent="var(--primary)" className="border-r border-sidebar-border" > <SidebarHeader className="gap-1.5 px-2 pt-2 pb-1"> <button type="button" onClick={() => toast.add({ title: "Cedarline", description: "This account has one workspace.", }) } className="flex h-10 min-w-0 items-center gap-2 rounded-lg px-1.5 text-left outline-none transition-colors duration-150 hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring/50" > <MonogramTile name="Cedarline" color="teal" size="lg" /> <span className="min-w-0 flex-1 truncate text-13 font-medium"> Cedarline </span> <ChevronsUpDownIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> </button> <button type="button" onClick={() => toast.add({ title: "Search opens the command menu", description: "Press ⌘K from anywhere in the workspace.", }) } className="flex h-8 min-w-0 items-center gap-2 rounded-lg bg-background px-2 text-13 text-muted-foreground shadow-border outline-none transition-[box-shadow,color] duration-150 hover:text-foreground hover:shadow-border-hover focus-visible:ring-3 focus-visible:ring-ring/40" > <SearchIcon aria-hidden="true" className="size-3.5 shrink-0" /> <span className="truncate">Search</span> <Kbd className="ml-auto">⌘K</Kbd> </button> </SidebarHeader> <SidebarContent> <nav aria-label="Cedarline workspace"> {groups.map((group) => ( <SidebarGroup key={group.label ?? "main"}> {group.label ? ( <SidebarGroupLabel> {group.label} </SidebarGroupLabel> ) : null} <SidebarGroupContent> <SidebarMenu> {group.items.map((item) => ( <SidebarMenuItem key={item.id}> <SidebarMenuButton type="button" isActive={ active === item.id } aria-current={ active === item.id ? "page" : undefined } onClick={() => setActive(item.id) } > <item.icon aria-hidden="true" /> <span> {item.label} </span> </SidebarMenuButton> {item.count ? ( <SidebarMenuBadge> {item.count} </SidebarMenuBadge> ) : null} </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> ))} </nav> </SidebarContent> <SidebarFooter className="flex-row items-center gap-1 border-t border-sidebar-border"> <span className="flex min-w-0 flex-1 items-center gap-2 px-1"> <Avatar size="sm"> <AvatarFallback>MO</AvatarFallback> </Avatar> <span className="truncate text-13"> Maya Okafor </span> </span> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => setActive("settings")} /> } > <SettingsIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Settings</TooltipContent> </Tooltip> </SidebarFooter> </Sidebar> <div className="flex min-w-0 flex-1 flex-col bg-background max-sm:hidden"> <div className="flex h-12 shrink-0 items-center border-b border-border px-4 text-13 font-medium"> {current?.label ?? "Settings"} </div> <p className="p-4 text-13 text-muted-foreground"> Pick another page in the rail. The highlight glides to it. </p> </div> </SidebarProvider> </div> );}Usage#
Sidebar is the app rail: a provider that owns the open state and ⌘B, a shell that draws a 16rem Cool Rail on desktop and an 18rem sheet below 768px, and a kit of groups, menus, badges and sub-menus to fill it. Every workspace screen sits beside it (collapsing to a 3rem icon rail), and these docs use it too. The part people get wrong is the current page: mark it with isActive and aria-current="page", pass accent to the Sidebar, and let the gliding mark draw it. Don't paint the active row yourself.
When to use
- For the persistent, grouped navigation of a whole workspace or app, beside the content plane.
- When the rail should collapse to icons with tooltips, or slide away entirely, and come back with ⌘B.
- For long navigation that needs group labels, counts, per-item menus and one level of nesting.
- When the same navigation must work on a phone: below 768px it becomes a sheet with no extra code.
When not to use
- For switching between two to six panels inside one page or card. Use Tabs
- For the sections of one settings page or record, in a column beside the form. Use Side tabs
- For a horizontal top bar with flyout panels, as on a marketing site. Use Navigation menu
- For jumping anywhere by name from the keyboard. Use Command menu
- For showing where a record sits in a hierarchy. Use Breadcrumb
One current page, one mark
isActive: the page you're on. Its surface and 3px tick come from the gliding mark, in the accent color. When a collapsed group or parent hides the current page, that label or parent carries isActive instead.The Quiet Indigo Rule
var(--primary)); the workspace uses the current app's identity color. Icons, labels and badges stay neutral.The Thirteen-Fourteen Rule
Anatomy#
- Header.
SidebarHeader: the 40px workspace switcher and the 32px search field with hairline lift and a ⌘K key. - Group label.
SidebarGroupLabel: 12px medium Slate Meta, sentence case. In the icon rail it slides up and fades out. - Menu button.
SidebarMenuButton: a 16px Slate Meta icon and a 13px label with a 10px gap, 8px corners, Rail Highlight on hover. - Active row. The gliding surface (Card White with hairline lift) and a 3px tick in the
accentcolor at the rail's left edge. - Badge.
SidebarMenuBadge: a count at the right of a row, in tabular figures. Hidden in the icon rail. - Sub-menu.
SidebarMenuSub: one nested level on a hairline under the parent's icon. Sub-items are 13px Slate Meta and darken on hover; the active one's tick sits on that line. - Footer.
SidebarFooter: the user menu, help and settings, divided from the content by a hairline.
Examples#
Collapse to icons
The workspace setup: collapsible="icon" with a trigger in the header, SidebarRail on the edge and a tooltip on every row. The demo contains the rail in a box by overriding the container to absolute h-full; in the app it is fixed to the viewport.
import { Kbd } from "@oration/canon/components/kbd";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Separator } from "@oration/canon/components/separator";import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, SidebarTrigger,} from "@oration/canon/components/sidebar";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { Building2Icon, CalendarClockIcon, FileTextIcon, ReceiptIcon, ShieldCheckIcon,} from "lucide-react";import * as React from "react";export function IconRail() { const items = [ { id: "invoices", label: "Invoices", icon: FileTextIcon }, { id: "payment-runs", label: "Payment runs", icon: CalendarClockIcon }, { id: "suppliers", label: "Suppliers", icon: Building2Icon }, { id: "remittances", label: "Remittances", icon: ReceiptIcon }, { id: "w9s", label: "W-9s", icon: ShieldCheckIcon }, ]; const [active, setActive] = React.useState("payment-runs"); const current = items.find((item) => item.id === active); return ( <div className="relative h-[24rem] w-full max-w-3xl overflow-hidden rounded-xl bg-background shadow-border"> <SidebarProvider className="h-full min-h-0"> <Sidebar collapsible="icon" accent="var(--primary)" className="absolute h-full border-sidebar-border" > <SidebarHeader> <span className="flex h-8 items-center gap-2 px-1 group-data-[collapsible=icon]:px-0"> <MonogramTile name="Cedarline" color="teal" size="md" /> <span className="truncate text-13 font-medium group-data-[collapsible=icon]:hidden"> Cedarline </span> </span> </SidebarHeader> <SidebarContent> <SidebarGroup> <SidebarGroupLabel>Payables</SidebarGroupLabel> <SidebarMenu> {items.map((item) => ( <SidebarMenuItem key={item.id}> <SidebarMenuButton type="button" tooltip={item.label} isActive={active === item.id} aria-current={ active === item.id ? "page" : undefined } onClick={() => setActive(item.id)} > <item.icon aria-hidden="true" /> <span>{item.label}</span> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> </SidebarContent> <SidebarRail /> </Sidebar> <div className="flex min-w-0 flex-1 flex-col bg-background"> <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3"> <Tooltip> <TooltipTrigger render={<SidebarTrigger />} /> <TooltipContent> Toggle sidebar <Kbd>⌘B</Kbd> </TooltipContent> </Tooltip> <Separator orientation="vertical" className="h-4" /> <span className="text-13 font-medium"> {current?.label} </span> </div> <p className="p-4 text-13 text-muted-foreground"> Collapse the rail with the button, the edge handle or ⌘B. Hover an icon to read its name. </p> </div> </SidebarProvider> </div> );}Variants and off canvas
Flush sidebar, a floating rail inset by 8px, or inset, which sets the content in a raised panel (SidebarInset applies it for you). With collapsible="offcanvas" the trigger slides the rail out entirely.
import { Kbd } from "@oration/canon/components/kbd";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,} from "@oration/canon/components/sidebar";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { Building2Icon, CalendarClockIcon, FileTextIcon } from "lucide-react";import * as React from "react";export function Variants() { const [variant, setVariant] = React.useState< "sidebar" | "floating" | "inset" >("floating"); const [active, setActive] = React.useState("suppliers"); const items = [ { id: "invoices", label: "Invoices", icon: FileTextIcon }, { id: "payment-runs", label: "Payment runs", icon: CalendarClockIcon }, { id: "suppliers", label: "Suppliers", icon: Building2Icon }, ]; return ( <div className="flex w-full max-w-3xl flex-col items-start gap-3"> <SegmentedControl label="Sidebar variant" value={variant} onValueChange={setVariant} options={[ { value: "sidebar", label: "Sidebar" }, { value: "floating", label: "Floating" }, { value: "inset", label: "Inset" }, ]} /> <div className="relative h-[20rem] w-full overflow-hidden rounded-xl bg-background shadow-border"> <SidebarProvider className="h-full min-h-0"> <Sidebar variant={variant} collapsible="offcanvas" accent="var(--primary)" className="absolute h-full border-sidebar-border" > <SidebarContent> <SidebarGroup> <SidebarGroupLabel>Payables</SidebarGroupLabel> <SidebarMenu> {items.map((item) => ( <SidebarMenuItem key={item.id}> <SidebarMenuButton type="button" isActive={active === item.id} aria-current={ active === item.id ? "page" : undefined } onClick={() => setActive(item.id) } > <item.icon aria-hidden="true" /> <span>{item.label}</span> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> </SidebarContent> </Sidebar> <div className={cn( "flex min-w-0 flex-1 flex-col bg-background", variant === "inset" && "m-2 ml-0 rounded-xl shadow-border peer-data-[state=collapsed]:ml-2", )} > <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3"> <Tooltip> <TooltipTrigger render={<SidebarTrigger />} /> <TooltipContent> Toggle sidebar <Kbd>⌘B</Kbd> </TooltipContent> </Tooltip> <span className="text-13 font-medium capitalize"> {variant} </span> </div> </div> </SidebarProvider> </div> </div> );}Groups, actions and badges
A group action adds to the group; SidebarMenuAction with showOnHover holds a row menu; SidebarMenuBadge shows work waiting, with its unit in screen-reader text.
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupAction, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarProvider,} from "@oration/canon/components/sidebar";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, PlusIcon } from "lucide-react";import * as React from "react";export function GroupsAndActions() { const views = [ { id: "needs-approval", label: "Needs approval" }, { id: "due-this-week", label: "Due this week" }, { id: "on-hold", label: "On hold" }, ]; const queues = [ { id: "exceptions", label: "Exceptions", count: 7 }, { id: "duplicates", label: "Possible duplicates", count: 2 }, { id: "missing-w9", label: "Missing W-9", count: 14 }, ]; const [active, setActive] = React.useState("due-this-week"); return ( <div className="h-[22rem] overflow-hidden rounded-xl shadow-border"> <SidebarProvider className="h-full min-h-0 w-auto"> <Sidebar collapsible="none" accent="var(--primary)"> <SidebarContent> <SidebarGroup> <SidebarGroupLabel>Views</SidebarGroupLabel> <Tooltip> <TooltipTrigger render={ <SidebarGroupAction type="button" aria-label="Add view" className="top-2" onClick={() => toast.add({ title: "New view", description: "Name it and pick its filters.", }) } /> } > <PlusIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Add view</TooltipContent> </Tooltip> <SidebarMenu> {views.map((view) => ( <SidebarMenuItem key={view.id}> <SidebarMenuButton type="button" isActive={active === view.id} aria-current={ active === view.id ? "page" : undefined } onClick={() => setActive(view.id)} > <span>{view.label}</span> </SidebarMenuButton> <DropdownMenu> <DropdownMenuTrigger render={ <SidebarMenuAction type="button" showOnHover aria-label={`Options for ${view.label}`} /> } > <MoreHorizontalIcon aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent side="right" align="start" > <DropdownMenuItem onClick={() => toast.add({ title: `Renaming ${view.label}`, }) } > Rename view </DropdownMenuItem> <DropdownMenuItem onClick={() => toast.add({ title: `${view.label} duplicated`, }) } > Duplicate </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" onClick={() => toast.add({ type: "error", title: `${view.label} deleted`, }) } > Delete view </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> <SidebarGroup> <SidebarGroupLabel>Queues</SidebarGroupLabel> <SidebarMenu> {queues.map((queue) => ( <SidebarMenuItem key={queue.id}> <SidebarMenuButton type="button" isActive={active === queue.id} aria-current={ active === queue.id ? "page" : undefined } onClick={() => setActive(queue.id)} > <span>{queue.label}</span> </SidebarMenuButton> <SidebarMenuBadge> {queue.count} <span className="sr-only"> {" "} invoices </span> </SidebarMenuBadge> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> </SidebarContent> </Sidebar> </SidebarProvider> </div> );}Loading
SidebarMenuSkeleton rows keep the rail's shape while views load. Render them only on the client; their width is random.
import { Button } from "@oration/canon/components/button";import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarProvider,} from "@oration/canon/components/sidebar";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { toast } from "@oration/canon/components/toast";import { FileTextIcon, RefreshCwIcon } from "lucide-react";import * as React from "react";export function Loading() { const [loading, setLoading] = React.useState(false); const views = [ "Needs approval", "Due this week", "On hold", "Paid in September", ]; const reload = () => { setLoading(true); window.setTimeout(() => setLoading(false), 1400); }; return ( <div className="flex flex-col items-center gap-3"> <div className="h-[15rem] overflow-hidden rounded-xl shadow-border"> <SidebarProvider className="h-full min-h-0 w-auto"> <Sidebar collapsible="none"> <SidebarContent> <SidebarGroup> <SidebarGroupLabel>Views</SidebarGroupLabel> <SkeletonReveal loading={loading} label="Loading views" skeleton={ <SidebarMenu> {views.map((view) => ( <SidebarMenuItem key={view}> <SidebarMenuSkeleton showIcon /> </SidebarMenuItem> ))} </SidebarMenu> } > <SidebarMenu> {views.map((view) => ( <SidebarMenuItem key={view}> <SidebarMenuButton type="button" onClick={() => toast.add({ title: `Opened ${view}`, }) } > <FileTextIcon aria-hidden="true" /> <span>{view}</span> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SkeletonReveal> </SidebarGroup> </SidebarContent> </Sidebar> </SidebarProvider> </div> <Button type="button" variant="outline" size="sm" onClick={reload} disabled={loading} > <RefreshCwIcon data-icon="inline-start" aria-hidden="true" /> Reload views </Button> </div> );}States#
import { Sidebar, SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider,} from "@oration/canon/components/sidebar";import { cn } from "@oration/canon/lib/utils";import { FileTextIcon } from "lucide-react";export function StatesMatrix() { return ( <SidebarProvider className="min-h-0 w-auto"> <Sidebar collapsible="none" className="h-auto rounded-xl shadow-border" > <SidebarGroup> <SidebarMenu className="gap-1"> {menuStates.map((state) => ( <SidebarMenuItem key={state.label}> <SidebarMenuButton type="button" tabIndex={-1} isActive={state.active} disabled={state.disabled} className={cn( "pointer-events-none", state.className, )} > <FileTextIcon aria-hidden="true" /> <span>{state.label}</span> </SidebarMenuButton> {state.tick ? ( <span aria-hidden="true" className="absolute top-1/2 -left-[5px] h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary" /> ) : null} </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> </Sidebar> </SidebarProvider> );}| State | Treatment |
|---|---|
| Rest | Rail Ink text and Slate Meta icons on Cool Rail. |
| Hover | Rail Highlight fill (white at 5% in dark) and foreground text. The active row never takes the hover fill, so nothing covers its card. |
| Focus visible | A 2px ring in --sidebar-ring (indigo) around the row. |
| Pressed | Rail Highlight while held. |
| Active, no accent | isActive without accent: the row paints the same Card White surface with hairline lift itself, with medium weight and a foreground icon. No tick, no glide. |
| Active with accent | The row's own fill clears and the shared surface and tick draw it. Switching rows glides both over 240ms with no bounce. During the 200ms width ease the row paints its card again and the tick stays. |
| Open | A row whose menu is open (data-popup-open) keeps the hover fill. |
| Disabled | disabled or aria-disabled: 50% opacity and no pointer events. |
| Icon rail | collapsible="icon" and collapsed: 3rem wide, rows become 32px squares, labels and badges hide, group labels fade and tooltips name each row. |
| Off canvas | collapsible="offcanvas" and collapsed: the rail slides out to the left and the content takes the width. |
| Mobile | Below 768px the rail renders in an 18rem sheet from its side, opened by the trigger. |
| Loading | SidebarMenuSkeleton rows at the row height, with an optional icon block. |
Behavior#
SidebarProviderownsopen(desktop) andopenMobile(sheet). PassdefaultOpen, oropenandonOpenChangeto control it.useSidebar()reads the state andtoggleSidebar()from any descendant; it throws outside a provider.- ⌘B (Ctrl+B) toggles the rail from anywhere: the desktop state, or the sheet on mobile. Each change writes the
sidebar_statecookie for 7 days; read it on the server to passdefaultOpen. SidebarTriggeris a 28px ghost icon button that toggles;SidebarRailis a 16px hit strip on the rail's edge that toggles on click and shows a 2px hairline on hover. The rail is skipped by Tab.- The rail's width eases over 200ms.
collapsible="none"renders a static rail with no collapse, no sheet and no fixed positioning. - Below 768px (
useIsMobile) the rail moves into a Sheet with a screen-reader title. Close it on navigation yourself: callsetOpenMobile(false)in each link'sonClick, as both product sidebars do. accentturns on the gliding mark. It measures the active row and springs to the next one (240ms, no bounce), sticks to its row on scroll and resize, is clipped to the scroll area so a row scrolled out of view takes its mark with it, rides the row during the 200ms width ease, and fades out 180ms after no row is active. Under reduced motion it jumps.- A collapsed group label with
isActivewins the mark over the row it hides, and an active sub-item wins over its parent. In the icon rail the mark stays on the icon row. tooltipon a menu button only shows while the rail is collapsed to icons, on the right, and never on mobile.showOnHoveronSidebarMenuActionhides the action until the row is hovered or focused, or its menu is open. On touch it is always visible.
Do and don't#
isActive and aria-current, pass accent, and let the gliding mark draw the surface and tick.tooltip with its label, and keep the header and footer icon-only too.Content#
- Item labels are the page's name, one or two words, sentence case: Invoices, Payment runs, W-9s.
- Group labels are nouns that sort the items: Payables, Compliance, Views. Skip the label for the first, ungrouped block.
- Counts only for work waiting on the viewer: Approvals 12, Exceptions 7. Add screen-reader text for the unit ("12 invoices").
- Saved views are named for their filter: Due this week, On hold, not My view 2.
- Tooltips in the icon rail repeat the label exactly.
Accessibility#
- Wrap the menus in
<nav aria-label>;SidebarMenuis a<ul>and each item an<li>, so screen readers announce the count. isActiveonly setsdata-active. Addaria-current="page"to the current row yourself.- In the icon rail, labels stay in the DOM (clipped, not removed), so every row keeps its accessible name; the tooltip is for sighted users.
SidebarTriggercarries screen-reader text; wrap it in a Tooltip that names the shortcut.SidebarRailistabIndex={-1}, so keyboard users toggle with the trigger or ⌘B.- The mobile sheet traps focus, closes on Escape or the scrim, and returns focus to the trigger.
- Counts in
SidebarMenuBadgeneed screen-reader text for the unit, and a disclosure parent needsaria-expanded. - Under reduced motion the active mark jumps instead of gliding.
| Keys | Action |
|---|---|
| ⌘B | Toggles the rail (Ctrl+B on Windows). |
| Tab | Moves through rows, actions and the footer. |
| Enter | Follows the focused link or runs the row. |
| Space | Activates a button row. |
| Esc | Closes the mobile sheet. |
Design tokens#
| Token | Used for |
|---|---|
--sidebar | Cool Rail, the rail background |
--sidebar-foreground | Rail Ink, row text |
--sidebar-accent | Rail Highlight: hover, pressed and active fill |
--sidebar-accent-foreground | Text on the highlight |
--sidebar-border | Rail edge, footer rule, sub-menu line |
--sidebar-ring | Focus ring on rows |
--sidebar-mark | Set from accent: the 3px tick and its glow |
--sidebar-width | 16rem desktop, 18rem in the mobile sheet |
--sidebar-width-icon | 3rem icon rail |
bg-card shadow-border | The gliding active surface (white at 9% in dark) |
--radius-md | 8px row corners |
API reference#
SidebarProvider
Owns open state, the ⌘B shortcut and the cookie. Renders a flex wrapper that sets --sidebar-width and --sidebar-width-icon.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | true | Initial desktop state when uncontrolled. |
open | boolean | No default | Controlled desktop state. |
onOpenChange | (open: boolean) => void | No default | Called when the desktop state changes. |
className | string | No default | Merged after flex min-h-svh w-full. Pass min-h-0 to contain it in a box. |
Sidebar
The rail. Desktop: a fixed container plus a gap that reserves its width. Mobile: a Sheet.
Other props spread onto <div> (the sidebar container).
| Prop | Type | Default | Description |
|---|---|---|---|
side | "left" | "right" | "left" | Edge the rail sits on and the sheet slides from. |
variant | "sidebar" | "floating" | "inset" | "sidebar" | sidebar is flush with a hairline edge; floating insets the rail 8px with a ring; inset sets the content in a raised panel. |
collapsible | "offcanvas" | "icon" | "none" | "offcanvas" | How it collapses: slide away, shrink to a 3rem icon rail, or never. |
accent | string | No default | Color of the active-row mark, such as var(--primary). Omit to draw no mark; active rows still paint their card. |
className | string | No default | Applied to the container (or the static rail when none). |
SidebarTrigger
A ghost icon-sm Button with a panel icon that toggles.
Other props spread onto Button.
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (event: MouseEvent) => void | No default | Runs before the toggle. |
SidebarRail
An invisible 16px strip on the rail's edge that toggles on click.
Other props spread onto <button>.
No props of its own.
SidebarInset
The content column beside the rail, as <main>. With variant="inset" it becomes a raised panel with 8px margins.
Other props spread onto <main>.
No props of its own.
SidebarContent
The scrolling middle. Hides its scrollbar; clips in the icon rail.
Other props spread onto <div>.
No props of its own.
SidebarSeparator
A Separator in --sidebar-border with 8px side margins.
Other props spread onto Separator.
No props of its own.
SidebarInput
An Input at 32px on White Plane without a shadow.
Other props spread onto Input.
No props of its own.
SidebarGroup
A relative block with 8px side and 4px vertical padding.
Other props spread onto <div>.
No props of its own.
SidebarGroupLabel
A 28px label row in 12px medium Slate Meta. Render it as a button to fold a group.
Other props spread onto <div> (via render).
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Draws the mark on this label, for a folded group that holds the current page. |
render | ReactElement | (props, state) => ReactElement | No default | Render as a <button> for a collapsible group. |
SidebarGroupAction
A 20px icon button pinned to the group label's right. Hidden in the icon rail.
Other props spread onto <button> (via render).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render as another element. |
SidebarGroupContent
The group's body.
Other props spread onto <div>.
No props of its own.
SidebarMenu, SidebarMenuItem
The <ul> and its relative <li> rows.
Other props spread onto <ul>, <li>.
No props of its own.
SidebarMenuButton
The row. A <button> by default; render a Link for pages.
Other props spread onto <button> (via render).
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the current page (data-active). Add aria-current yourself. |
variant | "default" | "outline" | "default" | outline adds a White Plane fill and a hairline ring. |
size | "default" | "sm" | "lg" | "default" | 28px at 13px (the nav row), 24px at 12px, or 48px. |
tooltip | string | TooltipContent props | No default | Shown on the right while collapsed to icons. |
render | ReactElement | (props, state) => ReactElement | No default | Render as <Link href /> for navigation. |
SidebarMenuAction
A 20px icon button at a row's right, for a row menu or quick add.
Other props spread onto <button> (via render).
| Prop | Type | Default | Description |
|---|---|---|---|
showOnHover | boolean | false | Hidden on desktop until the row is hovered or focused, or its menu is open. |
render | ReactElement | (props, state) => ReactElement | No default | Render as another element. |
SidebarMenuBadge
A count at a row's right: 20px, tabular, not interactive. Hidden in the icon rail.
Other props spread onto <div>.
No props of its own.
SidebarMenuSkeleton
A loading row with a random 50 to 90% text width.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
showIcon | boolean | false | Adds a 16px icon block. |
SidebarMenuSub, SidebarMenuSubItem
A nested <ul> on a hairline under the parent row's icon, and its <li>. Hidden in the icon rail.
Other props spread onto <ul>, <li>.
No props of its own.
SidebarMenuSubButton
A 28px nested row. An <a> by default.
Other props spread onto <a> (via render).
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the current page; its tick sits on the sub-menu line. |
size | "sm" | "md" | "md" | 13px on 28px, or 12px on 24px. |
render | ReactElement | (props, state) => ReactElement | No default | Render as <Link href /> or a <button>. |
useSidebar
Returns { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar }. state is "expanded" or "collapsed".
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
With accent, the active row is drawn as a Card White surface with hairline lift and a colored tick. DESIGN.md's Navigation section still describes a Rail Highlight fill; one of them should change.
isActive sets data-active but not aria-current, so every call site must add it.
Every SidebarProvider listens for ⌘B on window and writes the same sidebar_state cookie. Nested providers, like the demos on this page inside the docs shell, all toggle together. The provider never reads the cookie back.
The screen-reader strings are hard-coded: the trigger and rail say Toggle Sidebar in title case, and the mobile sheet's description reads Switch apps and move between pages even in these docs.
The 200ms width ease and the group label slide are plain CSS transitions with no motion-reduce variant; only the active mark respects reduced motion.
floating uses shadow-sm with a ring and inset uses shadow-sm on SidebarInset, not the hairline lift. Neither variant is used in the product.
SidebarInput isn't used: both product rails hand-roll the search field as a button with hairline lift and a ⌘K key, because it opens the command menu.
SidebarMenuSkeleton picks its width with Math.random() in state, so a server-rendered skeleton hydrates with a different width than the client's.