Use SidebarProvider around both sidebar and main content. Sidebar is an application layout, not just a list of links. Installation includes the mobile detection hook and the component source.
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "./_sidebar";
const metrics = [
{ label: "Projects", value: "12", detail: "Active in this sample workspace" },
{ label: "Team members", value: "8", detail: "Working across three teams" },
{ label: "Open tasks", value: "24", detail: "Ready for review this week" },
];
const activity = [
{ title: "Button reference updated", detail: "Variants, sizes, and navigation guidance" },
{ title: "Sidebar install verified", detail: "Component source and mobile hook included" },
{ title: "Theme export checked", detail: "Registry tokens match the customizer output" },
];
export default function Page() {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b-2 border-border transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex min-w-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/docs">Documentation</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Workspace overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
<section aria-labelledby="sidebar-overview-heading">
<div className="mb-4">
<p className="text-sm font-base text-foreground/70">Workspace</p>
<h2 id="sidebar-overview-heading" className="text-2xl font-heading">
Workspace overview
</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{metrics.map((metric) => (
<article
key={metric.label}
className="rounded-base border-2 border-border bg-background p-4 shadow-shadow"
>
<p className="text-sm font-base text-foreground/70">{metric.label}</p>
<p className="mt-2 text-3xl font-heading">{metric.value}</p>
<p className="mt-2 text-sm leading-6">{metric.detail}</p>
</article>
))}
</div>
</section>
<section
aria-labelledby="sidebar-activity-heading"
className="rounded-base border-2 border-border bg-background p-4 shadow-shadow"
>
<div className="flex flex-wrap items-end justify-between gap-3 border-b-2 border-border pb-4">
<div>
<p className="text-sm font-base text-foreground/70">Review queue</p>
<h2 id="sidebar-activity-heading" className="text-xl font-heading">
Recent documentation work
</h2>
</div>
<a className="font-heading underline underline-offset-4" href="/docs/installation">
Installation guide
</a>
</div>
<ul className="divide-y-2 divide-border">
{activity.map((item) => (
<li key={item.title} className="grid gap-1 py-4 sm:grid-cols-[minmax(0,1fr)_auto]">
<div>
<h3 className="font-heading">{item.title}</h3>
<p className="text-sm leading-6 text-foreground/75">{item.detail}</p>
</div>
<span className="mt-1 text-sm font-heading sm:mt-0">Ready</span>
</li>
))}
</ul>
</section>
</div>
</SidebarInset>
</SidebarProvider>
);
}
"use client";
import {
AudioWaveform,
BadgeCheck,
Bell,
BookOpen,
Bot,
ChevronRight,
ChevronsUpDown,
Command,
CreditCard,
Folder,
Forward,
Frame,
GalleryVerticalEnd,
LogOut,
Map,
MoreHorizontal,
PieChart,
Plus,
Settings2,
Sparkles,
SquareTerminal,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
// This is sample data.
const data = {
user: {
name: "John Doe",
email: "hello@example.com",
},
teams: [
{
name: "Northstar Studio",
logo: GalleryVerticalEnd,
plan: "Enterprise",
},
{
name: "Draft Lab",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Common Ground",
logo: Command,
plan: "Free",
},
],
navMain: [
{
title: "Playground",
url: "/docs/sidebar",
icon: SquareTerminal,
isActive: true,
items: [
{
title: "History",
url: "/docs/sidebar",
},
{
title: "Starred",
url: "/docs/sidebar",
},
{
title: "Settings",
url: "/docs/sidebar",
},
],
},
{
title: "Models",
url: "/docs/sidebar",
icon: Bot,
items: [
{
title: "Genesis",
url: "/docs/sidebar",
},
{
title: "Explorer",
url: "/docs/sidebar",
},
{
title: "Quantum",
url: "/docs/sidebar",
},
],
},
{
title: "Documentation",
url: "/docs/sidebar",
icon: BookOpen,
items: [
{
title: "Introduction",
url: "/docs/",
},
{
title: "Get Started",
url: "/docs/installation",
},
{
title: "Tutorials",
url: "/docs/resources",
},
{
title: "Changelog",
url: "/docs/credits",
},
],
},
{
title: "Settings",
url: "/docs/sidebar",
icon: Settings2,
items: [
{
title: "General",
url: "/docs/sidebar",
},
{
title: "Team",
url: "/docs/sidebar",
},
{
title: "Billing",
url: "/docs/sidebar",
},
{
title: "Limits",
url: "/docs/sidebar",
},
],
},
],
projects: [
{
name: "Design Engineering",
url: "/docs/sidebar",
icon: Frame,
},
{
name: "Sales & Marketing",
url: "/docs/sidebar",
icon: PieChart,
},
{
name: "Travel",
url: "/docs/sidebar",
icon: Map,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { isMobile } = useSidebar();
const [activeTeam, setActiveTeam] = React.useState(data.teams[0]);
if (!activeTeam) {
return null;
}
return (
<Sidebar className="md:static" collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
className="focus-visible:ring-0"
render={
<SidebarMenuButton
size="lg"
className="data-open:bg-main data-open:text-main-foreground data-open:outline-2 data-open:outline-border data-popup-open:bg-main data-popup-open:text-main-foreground data-popup-open:outline-2 data-popup-open:outline-border"
/>
}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-base">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-heading">{activeTeam.name}</span>
<span className="truncate font-base text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-base"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-sm font-heading">Teams</DropdownMenuLabel>
{data.teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-1.5"
>
<div className="flex size-6 items-center justify-center">
<team.logo className="size-4 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-1.5">
<div className="flex size-6 items-center justify-center">
<Plus className="size-4" />
</div>
<div className="font-base">Add team</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{data.navMain.map((item) => (
<Collapsible
key={item.title}
defaultOpen={item.isActive}
className="group/collapsible"
render={<SidebarMenuItem />}
>
<CollapsibleTrigger
render={
<SidebarMenuButton
className="group-data-open/collapsible:bg-main group-data-open/collapsible:text-main-foreground group-data-open/collapsible:outline-border"
tooltip={item.title}
/>
}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-open/collapsible:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
render={
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
}
/>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{data.projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
render={
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
}
/>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuAction />}>
<MoreHorizontal className="group-hover/menu-item:text-main-foreground" />
<span className="sr-only">More</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton>
<MoreHorizontal />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
className="group-data-[state=collapsed]:hover:outline-0 group-data-[state=collapsed]:hover:bg-transparent overflow-visible"
size="lg"
/>
}
>
<Avatar className="h-8 w-8">
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-heading">{data.user.name}</span>
<span className="truncate text-xs">{data.user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-base">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8">
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-heading">{data.user.name}</span>
<span className="truncate text-xs">{data.user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
#Installation
Complete the base setup. The CLI installs both files and dependencies. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/sidebar.jsonnpm install @base-ui/react@^1.8.0Also install Avatar, Collapsible, Dropdown Menu, Button, Sheet, and Tooltip.
Create components/ui/sidebar.tsx:
"use client";
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { PanelLeftIcon } from "lucide-react";
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
openMobile: boolean;
setOpenMobile: React.Dispatch<React.SetStateAction<boolean>>;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback<React.Dispatch<React.SetStateAction<boolean>>>(
(value) => {
const openState = typeof value === "function" ? value(open) : value;
if (openProp === undefined) _setOpen(openState);
setOpenProp?.(openState);
},
[setOpenProp, openProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
style,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
dir={dir}
data-slot="sidebar"
style={style}
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-secondary-background text-foreground",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className={cn(
"w-(--sidebar-width) bg-secondary-background p-0 text-foreground",
className,
)}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
...style,
} as React.CSSProperties
}
side={side}
showCloseButton={false}
{...props}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
dir={dir}
className="group peer hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
style={style}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear group-data-[collapsible=offcanvas]:invisible data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-2 group-data-[side=left]:border-r-border group-data-[side=right]:border-l-2 group-data-[side=right]:border-l-border",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-secondary-background group-data-[variant=floating]:rounded-base group-data-[variant=floating]:border-2 group-data-[variant=floating]:border-border group-data-[variant=floating]:shadow-shadow"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="noShadow"
size="icon"
aria-expanded={isMobile ? openMobile : open}
className={(state) =>
cn("size-7", typeof className === "function" ? className(state) : className)
}
onClick={
mergeProps<"button">(
{
onClick: (event) => {
if (!event.defaultPrevented) toggleSidebar();
},
},
{ onClick },
).onClick
}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
type="button"
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
title="Toggle Sidebar"
className={cn(
"visible absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...mergeProps<"button">(
{
onClick: (event) => {
if (!event.defaultPrevented) toggleSidebar();
},
},
props,
)}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-secondary-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-base md:peer-data-[variant=inset]:shadow-sm md:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 border-b-2 border-b-border p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 border-t-2 border-t-border p-2", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn(
"relative flex w-full min-w-0 flex-col border-b-2 border-b-border p-2 last:border-b-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupLabel({
children,
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-base px-2 text-sm font-heading text-foreground ring-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-1 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
children,
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative font-base", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-base p-2 text-left text-sm ring-ring outline-1 outline-transparent transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-main hover:text-main-foreground hover:outline-border focus-visible:bg-main focus-visible:text-main-foreground focus-visible:outline-border active:bg-main active:text-main-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-main data-open:hover:text-main-foreground data-active:bg-main data-active:font-heading data-active:text-main-foreground data-active:outline-border [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-main hover:text-main-foreground",
outline: "bg-secondary-background outline-border hover:bg-main hover:text-main-foreground",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
render,
children,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
children?: React.ReactNode;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? undefined : "button",
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
children,
},
props,
),
render,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return comp;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger render={comp} />
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
children,
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? undefined : "button",
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-6 items-center justify-center rounded-base p-0 text-main-foreground outline-1 outline-transparent transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-main hover:text-main-foreground hover:outline-border focus-visible:ring-1 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0 [&_svg]:text-foreground hover:[&_svg]:text-main-foreground",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-main-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
children,
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l-2 border-l-foreground/50 px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
render,
children,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
children?: React.ReactNode;
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-base px-2 text-foreground outline-1 outline-transparent group-data-[collapsible=icon]:hidden hover:bg-main hover:text-main-foreground hover:outline-border focus-visible:ring-1 active:bg-main disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-main data-active:outline-border [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-main-foreground",
className,
),
children,
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
};
Create hooks/use-mobile.ts:
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
The default keyboard toggle is Ctrl+B or Command+B. Check for conflicts with your application shortcuts.
The complete live preview additionally uses Breadcrumb. Install it before copying the Code example, then save both page.tsx and _sidebar.tsx from that tab in the same directory. Its workspace metrics are illustrative, and the avatar uses initials so no image asset is required. Replace the sample navigation destinations with your application's routes.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/breadcrumb.json#Usage
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function ApplicationLayout() {
return (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<nav aria-label="Workspace navigation">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="/docs">Documentation</a>} />
</SidebarMenuItem>
</SidebarMenu>
</nav>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="p-4">
<SidebarTrigger />
</header>
<section className="p-4">
<h1>Workspace overview</h1>
</section>
</SidebarInset>
</SidebarProvider>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
SidebarProvider.defaultOpen | Initial desktop expanded state | true |
SidebarProvider.open / onOpenChange | Controlled desktop state | Uncontrolled |
Sidebar.side | left or right | left |
Sidebar.variant | sidebar, floating, or inset | sidebar |
Sidebar.collapsible | offcanvas, icon, or none | offcanvas |
SidebarTrigger / useSidebar | Toggle and context access | Provider required |
SidebarMenuButton.isActive | Active link styling | false |
SidebarMenuButton.variant | Menu button appearance: default or outline | default |
SidebarMenuButton.size | Menu button size: default, sm, or lg | default |
SidebarMenuAction.showOnHover | Reveal the action on hover or keyboard focus | false |
SidebarMenuSubButton.size | Nested link size: sm or md | md |
SidebarMenuSubButton.isActive | Active nested-link styling | false |
#Accessibility
Name navigation regions and give links destinations and readable labels. Collapsed icon-only items still need names. The mobile version behaves as a modal sheet; check focus and scroll at both sizes. Persistence is application-owned. Initialize defaultOpen or control open / onOpenChange from your own storage; the component does not write cookies.
In desktop offcanvas mode, collapsed navigation is hidden from keyboard focus and assistive technology. Keep a SidebarTrigger outside the sidebar so it can be reopened. SidebarRail remains available for pointer users.
onOpenChange also observes changes when open is uncontrolled. Trigger and rail click handlers run before toggling and can cancel it with event.preventDefault(). A disabled SidebarMenuButton stays disabled when it has a tooltip.
#Offcanvas navigation
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function OffcanvasSidebar() {
return (
<SidebarProvider defaultOpen={false} className="relative isolate min-h-64 overflow-hidden">
<Sidebar className="md:absolute md:h-full">
<SidebarContent>
<nav aria-label="Offcanvas navigation" className="p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="/docs">Documentation</a>} />
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="/docs/installation">Installation</a>} />
</SidebarMenuItem>
</SidebarMenu>
</nav>
</SidebarContent>
<SidebarRail />
</Sidebar>
<div className="flex flex-1 flex-col items-start gap-4 p-4">
<SidebarTrigger />
<p>Open the navigation, then collapse it to return to the workspace.</p>
<Button variant="neutral">Continue working</Button>
</div>
</SidebarProvider>
);
}