Sidebar
The sidebar from the Notra dashboard, a shadcn-compatible sidebar on Base UI that collapses to an icon rail and resizes from its edge.
The sidebar keeps the shadcn API (SidebarProvider, Sidebar, SidebarMenuButton and the rest) on Base UI primitives. On top of that it collapses to an icon rail without the rows jumping, resizes from its edge, and reveals sub-rows with a Base UI Collapsible. Tooltips on the rail glide between rows with the Notra Tooltip.
import { LogOutIcon, PlusIcon, SettingsIcon, SparklesIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { SidebarNav } from "../components/sidebar-nav";
import { SidebarSearch } from "../components/sidebar-search";
import {
SidebarHeaderRow,
SidebarSwitcher,
} from "../components/sidebar-switcher";
import { SidebarUserMenu } from "../components/sidebar-user-menu";
import {
DEMO_FOOTER_GROUPS,
DEMO_NAV_GROUPS,
DEMO_PAGE_TITLES,
DEMO_PROJECTS,
} from "../constants/app-sidebar-demo";
import type { SidebarLinkRender } from "../types/app-sidebar";
// The preview frame is padded, so the sidebar sits in the shell instead of
// being fixed to the viewport as it is in an app.
const DEMO_SHELL_CLASS_NAME =
"relative h-160 min-h-0 overflow-hidden [&_[data-slot=sidebar-container]]:absolute [&_[data-slot=sidebar-container]]:h-full";
function MobileTrigger() {
const { isMobile } = useSidebar();
return isMobile ? <SidebarTrigger /> : null;
}
export default function AppSidebarDemo({
defaultOpen = true,
defaultHref = "#traffic",
}: {
defaultOpen?: boolean;
defaultHref?: string;
}) {
const [project, setProject] = useState("notra");
const [activeHref, setActiveHref] = useState(defaultHref);
const linkRender: SidebarLinkRender = ({ children, ...props }) => (
<a
{...props}
onClick={(event) => {
props.onClick?.(event);
setActiveHref(props.href);
}}
>
{children}
</a>
);
return (
<SidebarProvider
className={DEMO_SHELL_CLASS_NAME}
defaultOpen={defaultOpen}
keyboardShortcut={null}
mobileBreakpoint={480}
>
<Sidebar collapsible="icon" variant="inset" className="border-none">
<SidebarHeader className="pt-px pb-0.5">
<SidebarHeaderRow trigger={<SidebarTrigger />}>
<SidebarSwitcher
options={DEMO_PROJECTS}
value={project}
onValueChange={setProject}
actions={
<>
<DropdownMenuItem>
<PlusIcon />
New project
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon />
Project settings
</DropdownMenuItem>
</>
}
/>
</SidebarHeaderRow>
</SidebarHeader>
<div className="px-2 pt-2.5 pb-3">
<SidebarSearch />
</div>
<SidebarContent>
<SidebarGroup className="py-0">
<SidebarNav
groups={DEMO_NAV_GROUPS}
activeHref={activeHref}
linkRender={linkRender}
/>
</SidebarGroup>
<SidebarGroup className="mt-auto gap-2 pb-0">
<SidebarNav
groups={DEMO_FOOTER_GROUPS}
activeHref={activeHref}
linkRender={linkRender}
/>
<Button
variant="secondary"
className="w-full group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:px-0"
aria-label="Ask agent"
>
<SparklesIcon className="hidden group-data-[collapsible=icon]:block" />
<span className="group-data-[collapsible=icon]:hidden">
Ask agent
</span>
</Button>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="pt-2">
<SidebarUserMenu name="Ada Lovelace" description="Notra" badge="Pro">
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</SidebarUserMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 overflow-hidden">
<header className="border-border flex h-12 shrink-0 items-center gap-2 border-b px-4">
<MobileTrigger />
<h1 className="text-sm font-medium">
{DEMO_PAGE_TITLES[activeHref] ?? "Overview"}
</h1>
</header>
<div className="grid flex-1 auto-rows-min gap-4 p-4 sm:grid-cols-3">
<div className="bg-muted/60 aspect-video rounded-xl" />
<div className="bg-muted/60 aspect-video rounded-xl" />
<div className="bg-muted/60 aspect-video rounded-xl" />
<div className="bg-muted/60 h-64 rounded-xl sm:col-span-3" />
</div>
</SidebarInset>
</SidebarProvider>
);
}
Installation
$ bunx --bun shadcn@latest add @notra/app-sidebarThis installs the notra-sidebar primitives into components/ui/sidebar.tsx and the dashboard parts (switcher, search, grouped navigation and user menu) into components/app-sidebar/. It pulls in Button, Tooltip and Dropdown Menu, plus shadcn’s Avatar, Collapsible, Input, Kbd, Separator, Sheet and Skeleton.
To install only the primitives, run bunx shadcn@latest add @notra/notra-sidebar. Both need a shadcn project on Base UI and Tailwind CSS v4.
Usage
Describe the navigation once. A row opens its href, and its items show underneath while the row or one of them is active. Pass linkRender to render your router’s link.
import { Link } from "@tanstack/react-router";
import { HouseIcon, NotebookPenIcon } from "lucide-react";
import { SidebarNav } from "@/components/app-sidebar/components/sidebar-nav";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarInset,
SidebarProvider,
SidebarRail,
} from "@/components/ui/sidebar";
const groups = [
{
title: "Overview",
href: "/",
icon: HouseIcon,
items: [{ title: "Traffic", href: "/traffic" }],
},
{ title: "Content", href: "/content", icon: NotebookPenIcon },
];
<SidebarProvider>
<Sidebar collapsible="icon" variant="inset">
<SidebarContent>
<SidebarGroup>
<SidebarNav
groups={groups}
activeHref={pathname}
linkRender={({ href, ...props }) => <Link {...props} to={href} />}
/>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>;
Icon rail
With collapsible="icon" the sidebar shrinks to a 48px rail. Rows keep their width animation: everything after the leading icon fades out instead of being removed, so nothing reflows mid-transition. Wrap deeper text in SidebarLabel to get the same fade.
On the rail, rows show their title as a tooltip, sub-rows fold away, and rows with sub-pages open them in a flyout menu once the collapse has finished.
import AppSidebarDemo from "./app-sidebar-demo";
export default function AppSidebarCollapsed() {
return <AppSidebarDemo defaultHref="#chat" defaultOpen={false} />;
}
Resizing
SidebarRail is the resize handle. Drag it between minWidth and maxWidth (240 to 400px by default), drag it past the minimum to collapse, and double-click it to restore defaultWidth. With focus, the arrow keys resize in 8px steps and Enter toggles the sidebar. Pass width and onWidthChange to SidebarProvider to store the width, for example in a cookie. Pass resizable={false} for shadcn’s click-to-toggle rail.
⌘B or Ctrl+B toggles the sidebar. Change it with keyboardShortcut, or pass null to turn it off.
Building blocks
| Component | What it does |
|---|---|
SidebarSwitcher |
The project switcher, a radio menu with optional actions. |
SidebarHeaderRow |
Places the trigger beside the switcher, and under it on the rail. |
SidebarSearch |
A search row styled like a field, with a shortcut hint. |
SidebarNav |
Grouped navigation with sub-rows, badges and the rail flyout. |
SidebarUserMenu |
The account row with an avatar, a plan badge and a menu. |
SidebarMenuCollapsible, SidebarMenuSubPanel |
Build your own expandable rows on the Base UI Collapsible. |
When the provider is narrower than mobileBreakpoint (768px by default), the sidebar opens as a sheet from its side. The width is measured on the provider rather than the viewport, so an embedded layout switches as well.