Skip to content

Sidebar

The app rail: workspace switcher, search, grouped navigation and the user menu.

Status
Beta
Category
Navigation
Adoption
Not used yet
import { SidebarProvider } from "@oration/canon/components/sidebar";
packages/canon/src/components/sidebar.tsx
MOMaya Okafor
Invoices

Pick another page in the rail. The highlight glides to it.

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

Exactly one row carries 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

The rail is Cool Rail and Rail Ink; the only color in it is the active mark. The docs use Quiet Indigo (var(--primary)); the workspace uses the current app's identity color. Icons, labels and badges stay neutral.

The Thirteen-Fourteen Rule

Nav items are Body Dense at 13px on 28px rows (36px at 14px below 768px), group labels are 12px medium Slate Meta, and counts are 11px Slate Meta. These are the component defaults; don't resize rows at the call site.

Anatomy#

Cedarline
Payables
MOMaya Okafor
  1. Header. SidebarHeader: the 40px workspace switcher and the 32px search field with hairline lift and a ⌘K key.
  2. Group label. SidebarGroupLabel: 12px medium Slate Meta, sentence case. In the icon rail it slides up and fades out.
  3. Menu button. SidebarMenuButton: a 16px Slate Meta icon and a 13px label with a 10px gap, 8px corners, Rail Highlight on hover.
  4. Active row. The gliding surface (Card White with hairline lift) and a 3px tick in the accent color at the rail's left edge.
  5. Badge. SidebarMenuBadge: a count at the right of a row, in tabular figures. Hidden in the icon rail.
  6. 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.
  7. 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.

Payment runs

Collapse the rail with the button, the edge handle or ⌘B. Hover an icon to read its name.

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.

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

Views
Queues
  • 7 invoices
  • 2 invoices
  • 14 invoices
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.

Views
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>    );}
States
StateTreatment
RestRail Ink text and Slate Meta icons on Cool Rail.
HoverRail Highlight fill (white at 5% in dark) and foreground text. The active row never takes the hover fill, so nothing covers its card.
Focus visibleA 2px ring in --sidebar-ring (indigo) around the row.
PressedRail Highlight while held.
Active, no accentisActive 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 accentThe 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.
OpenA row whose menu is open (data-popup-open) keeps the hover fill.
Disableddisabled or aria-disabled: 50% opacity and no pointer events.
Icon railcollapsible="icon" and collapsed: 3rem wide, rows become 32px squares, labels and badges hide, group labels fade and tooltips name each row.
Off canvascollapsible="offcanvas" and collapsed: the rail slides out to the left and the content takes the width.
MobileBelow 768px the rail renders in an 18rem sheet from its side, opened by the trigger.
LoadingSidebarMenuSkeleton rows at the row height, with an optional icon block.

Behavior#

  • SidebarProvider owns open (desktop) and openMobile (sheet). Pass defaultOpen, or open and onOpenChange to control it. useSidebar() reads the state and toggleSidebar() 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_state cookie for 7 days; read it on the server to pass defaultOpen.
  • SidebarTrigger is a 28px ghost icon button that toggles; SidebarRail is 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: call setOpenMobile(false) in each link's onClick, as both product sidebars do.
  • accent turns 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 isActive wins 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.
  • tooltip on a menu button only shows while the rail is collapsed to icons, on the right, and never on mobile.
  • showOnHover on SidebarMenuAction hides the action until the row is hovered or focused, or its menu is open. On touch it is always visible.

Do and don't#

Payables
Do. Mark the current page with isActive and aria-current, pass accent, and let the gliding mark draw the surface and tick.
Payables
Don't. Style the active row yourself with an indigo fill. It fights the mark, breaks the rail's calm and reads as a button.
Payables
Do. Write group labels as short sentence-case nouns: Payables, Compliance, Views.
Payables
Don't. Set group labels in uppercase with letter spacing. It's the eyebrow pattern Canon refuses.
Do. In the icon rail, give every row an icon and a tooltip with its label, and keep the header and footer icon-only too.
Don't. Collapse a rail whose rows have no icons, or ship icons with no tooltip. The collapsed rail becomes a row of unnamed squares.

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>; SidebarMenu is a <ul> and each item an <li>, so screen readers announce the count.
  • isActive only sets data-active. Add aria-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.
  • SidebarTrigger carries screen-reader text; wrap it in a Tooltip that names the shortcut. SidebarRail is tabIndex={-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 SidebarMenuBadge need screen-reader text for the unit, and a disclosure parent needs aria-expanded.
  • Under reduced motion the active mark jumps instead of gliding.
Keyboard interactions
KeysAction
⌘BToggles the rail (Ctrl+B on Windows).
TabMoves through rows, actions and the footer.
EnterFollows the focused link or runs the row.
SpaceActivates a button row.
EscCloses the mobile sheet.

Design tokens#

Design tokens
TokenUsed for
--sidebarCool Rail, the rail background
--sidebar-foregroundRail Ink, row text
--sidebar-accentRail Highlight: hover, pressed and active fill
--sidebar-accent-foregroundText on the highlight
--sidebar-borderRail edge, footer rule, sub-menu line
--sidebar-ringFocus ring on rows
--sidebar-markSet from accent: the 3px tick and its glow
--sidebar-width16rem desktop, 18rem in the mobile sheet
--sidebar-width-icon3rem icon rail
bg-card shadow-borderThe gliding active surface (white at 9% in dark)
--radius-md8px 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>.

Props of SidebarProvider
PropTypeDefaultDescription
defaultOpenbooleantrueInitial desktop state when uncontrolled.
openbooleanNo defaultControlled desktop state.
onOpenChange(open: boolean) => voidNo defaultCalled when the desktop state changes.
classNamestringNo defaultMerged 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).

Props of Sidebar
PropTypeDefaultDescription
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.
accentstringNo defaultColor of the active-row mark, such as var(--primary). Omit to draw no mark; active rows still paint their card.
classNamestringNo defaultApplied 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.

Props of SidebarTrigger
PropTypeDefaultDescription
onClick(event: MouseEvent) => voidNo defaultRuns 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.

SidebarHeader, SidebarFooter

Flex columns with 8px padding and gap.

Other props spread onto <div>.

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

Props of SidebarGroupLabel
PropTypeDefaultDescription
isActivebooleanfalseDraws the mark on this label, for a folded group that holds the current page.
renderReactElement | (props, state) => ReactElementNo defaultRender 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).

Props of SidebarGroupAction
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender 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).

Props of SidebarMenuButton
PropTypeDefaultDescription
isActivebooleanfalseMarks 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.
tooltipstring | TooltipContent propsNo defaultShown on the right while collapsed to icons.
renderReactElement | (props, state) => ReactElementNo defaultRender 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).

Props of SidebarMenuAction
PropTypeDefaultDescription
showOnHoverbooleanfalseHidden on desktop until the row is hovered or focused, or its menu is open.
renderReactElement | (props, state) => ReactElementNo defaultRender 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>.

Props of SidebarMenuSkeleton
PropTypeDefaultDescription
showIconbooleanfalseAdds 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).

Props of SidebarMenuSubButton
PropTypeDefaultDescription
isActivebooleanfalseMarks the current page; its tick sits on the sub-menu line.
size"sm" | "md""md"13px on 28px, or 12px on 24px.
renderReactElement | (props, state) => ReactElementNo defaultRender 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.