Sidebar
The app's side navigation: a provider, a collapsible rail of groups and menus, and the page beside it.
Vibra times the sidebar's width and slide on the theme's motion tokens (--duration-base, --ease-emphasized) instead of 200ms linear, sets group labels in the eyebrow register, and gives menu buttons the kit's solid focus outline instead of a 2px --sidebar-ring halo; the inset variant's page is ringed with a hairline and clipped rather than casting shadow-sm. SidebarProvider binds ⌘B / Ctrl+B to the toggle, leaving a press something else already answered, and below md the sidebar opens as a sheet, where menu buttons draw no tooltip — the sheet shows every label, and a tooltip opened on focus would take the Escape that closes it; SidebarTrigger reports aria-expanded — the rail's state on a laptop, the sheet's on a phone. Slid off the canvas, the desktop sidebar is inert and hidden from assistive technology, so nothing in it takes focus or is read out, and focus caught in it as it goes moves on to the toggle; a SidebarRail inside it is inert with it, and the toggle and ⌘B bring it back. contained frames a box on a page instead of the viewport — a shell previewed as one band of a page: the desktop sidebar is positioned in the box (absolute, as tall as the box) rather than fixed to the window, SidebarInset renders a plain region rather than a second <main>, ⌘B / Ctrl+B toggles only while focus is inside the box (the phone's portalled sheet included) and is left to the page everywhere else — the box takes focus from a click on its plain text, so the chord answers anywhere in it — the box is a stacking context of its own, so nothing layered in it rises over a host's sticky header, and the state is not written to the sidebar_state cookie, which belongs to the app's own rail. AppSidebar is the kit's ready-made one on top of it.
Install
npx shadcn@latest add @vibra/sidebarNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ChartLineIcon, FileTextIcon, LayoutGridIcon, SettingsIcon, UsersIcon } from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar"
const PAGES = [
{ title: "Overview", icon: LayoutGridIcon, summary: "1,204 paying workspaces and $48,290 in monthly revenue." },
{ title: "Analytics", icon: ChartLineIcon, summary: "84,120 of 100,000 events used this cycle — 84% of the plan." },
{ title: "Reports", icon: FileTextIcon, badge: 4, summary: "4 reports go out on Monday at 08:00." },
{ title: "Customers", icon: UsersIcon, badge: 128, summary: "128 customers joined this month, 9 of them on Pro." },
]
export default function SidebarDemo() {
const [page, setPage] = React.useState("Analytics")
const current = PAGES.find((item) => item.title === page) ?? PAGES[0]
return (
// A sidebar fills the viewport's edge in an app; here it is held open
// (collapsible="none") inside a frame, so the whole shell fits the preview.
<SidebarProvider
// The provider sets --sidebar-width inline; its style prop is where to narrow it.
style={{ "--sidebar-width": "12rem" } as React.CSSProperties}
className="h-64 min-h-0 w-full max-w-xl overflow-hidden rounded-xl ring-1 ring-border"
>
<Sidebar collapsible="none" className="border-r border-sidebar-border">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1">
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md bg-foreground text-2xs font-bold text-background"
>
N
</span>
<span className="truncate text-sm font-semibold text-sidebar-accent-foreground">Northwind</span>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{PAGES.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={page === item.title}
aria-current={page === item.title ? "page" : undefined}
onClick={() => setPage(item.title)}
>
<item.icon aria-hidden="true" />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge ? <SidebarMenuBadge className="tabular-nums">{item.badge}</SidebarMenuBadge> : null}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="sm">
<SettingsIcon aria-hidden="true" />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
{/* The page beside it. SidebarInset is this in an app, but it is a <main>,
and a docs page already has one. */}
<div className="flex min-w-0 flex-1 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center border-b border-rule px-4">
<h3 className="text-sm font-semibold">{current.title}</h3>
</div>
<p className="p-4 text-sm text-muted-foreground">{current.summary}</p>
</div>
</SidebarProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| SidebarProvider.defaultOpen / open / onOpenChange | boolean / boolean / (open: boolean) => void | true | Whether the desktop sidebar is expanded; pass open and onOpenChange to own it. |
| Sidebar.sheetTitle | string | "Sidebar" | What the phone's sheet is called — the name of the dialog a screen reader enters. AppSidebar names it for the product. |
| SidebarProvider.contained | boolean | false | Frames a box instead of the viewport: the sidebar is placed in the box, the inset is no second main, the box is its own stacking context, and ⌘B works only with focus inside it. |
| Sidebar.collapsible | "offcanvas" | "icon" | "none" | "offcanvas" | Slides away (inert once gone), shrinks to its icons, or stays put — none is a plain column, for a sidebar inside a panel. |
| Sidebar.variant | "sidebar" | "floating" | "inset" | "sidebar" | Flush against the edge, floating on the page, or the ground an inset page sits on. |
| Sidebar.side | "left" | "right" | "left" | The edge it sits on. |
| SidebarMenuButton.isActive | boolean | false | Marks the current page: the sidebar-accent fill and medium weight. |
| SidebarMenuButton.tooltip | string | TooltipContentProps | — | Shown when the sidebar is collapsed to icons, so an icon-only item keeps its name. |
| useSidebar | () => { state; open; setOpen; isMobile; openMobile; setOpenMobile; toggleSidebar } | — | The provider's state, for a trigger or layout of your own. |
Dependencies
Source
"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 { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
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: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
/** True under a provider that frames a box on a page rather than the viewport. */
contained: boolean
}
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
}
/** ⌘B on a Mac, Ctrl+B elsewhere: the chord that opens and closes the rail. */
function isToggleShortcut(event: { key: string; metaKey: boolean; ctrlKey: boolean }) {
return event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
contained = false,
className,
style,
onKeyDown,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
/**
* Frames a box on a page instead of the viewport — a shell shown as one
* band of a page, beside the page's own navigation. The desktop sidebar is
* positioned in the box rather than fixed to the window, the inset is a
* plain region rather than a second <main>, ⌘B / Ctrl+B toggles only while
* focus is inside the box (its portalled sheet included), and the state is
* never written to the sidebar_state cookie, which is the app's own shell's.
* The box's height is the caller's to give, or its content's.
*/
contained?: boolean
}) {
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(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state. A contained sidebar
// is a preview on some other page: its state is not the reader's
// choice for the app's own rail, which reads this cookie.
if (!contained) {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}
},
[setOpenProp, open, contained]
)
// 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. A contained sidebar
// listens on its own box instead (below), so the chord never reaches past it.
React.useEffect(() => {
if (contained) return
const handleKeyDown = (event: KeyboardEvent) => {
// A press already answered — by a contained box's own rail inside the
// app, say — is not this rail's to toggle as well.
if (event.defaultPrevented || !isToggleShortcut(event)) return
event.preventDefault()
toggleSidebar()
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar, contained])
// 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,
contained,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, contained]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
// data-slot is the caller's to rename (DashboardShell does);
// data-sidebar marks the wrapper for the sidebar inside it.
data-sidebar="wrapper"
data-slot="sidebar-wrapper"
data-contained={contained || undefined}
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
// A contained box takes focus from a click on its plain text, so the
// chord answers anywhere in it, not only once a control has focus.
tabIndex={contained ? -1 : undefined}
// React bubbles a key press out of a portal along the component tree,
// so the phone's sheet answers the chord here too.
onKeyDown={
contained
? (event) => {
onKeyDown?.(event)
if (event.defaultPrevented || !isToggleShortcut(event)) return
event.preventDefault()
toggleSidebar()
}
: onKeyDown
}
className={cn(
"group/sidebar-wrapper flex w-full has-data-[variant=inset]:bg-sidebar",
// The viewport's height in an app. A contained box is the edge the
// sidebar is positioned against, it clips the rail's slide, and it is
// a stacking context of its own: the rail's z-index and anything
// sticky inside stay under a host page's sticky header.
contained ? "relative isolate overflow-clip outline-none" : "min-h-svh",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
sheetTitle = "Sidebar",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
/**
* What the phone's sheet is called — the name of the dialog a screen reader
* enters. AppSidebar names it for the product; every shell's was "Sidebar".
*/
sheetTitle?: string
}) {
const { isMobile, state, openMobile, setOpenMobile, contained } = useSidebar()
const sidebarRef = React.useRef<HTMLDivElement>(null)
// Slid off the canvas, the sidebar is out of reach: inert and hidden from
// assistive technology, so nothing in it takes focus or is read out — it is
// gone from view, and a tab stop in it would land on nothing to see. Focus
// caught in it as it goes moves on first, to the toggle that brings it back
// (or to a contained box itself): a browser drops focus from an inert
// element at once, to the top of the page. So the attributes are set here,
// after that move, rather than rendered — the first render carries them,
// for the server's HTML, and nothing re-renders them after.
const folded = state === "collapsed" && collapsible === "offcanvas"
const [foldedAtFirst] = React.useState(folded)
React.useLayoutEffect(() => {
const sidebar = sidebarRef.current
if (!sidebar) return
if (folded && sidebar.contains(document.activeElement)) {
const wrapper = sidebar.closest<HTMLElement>("[data-sidebar=wrapper]")
const toggles = [...(wrapper?.querySelectorAll<HTMLElement>("[data-sidebar=trigger]") ?? [])]
;(toggles.find((toggle) => !sidebar.contains(toggle)) ?? wrapper)?.focus()
}
sidebar.toggleAttribute("inert", folded)
if (folded) sidebar.setAttribute("aria-hidden", "true")
else sidebar.removeAttribute("aria-hidden")
}, [folded, isMobile])
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={sidebarRef}
className="group peer hidden text-sidebar-foreground md:block"
inert={foldedAtFirst}
aria-hidden={foldedAtFirst || undefined}
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-(--duration-base) ease-(--ease-emphasized)",
"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}
className={cn(
"inset-y-0 z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-(--duration-base) ease-(--ease-emphasized) 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",
// Fixed to the window's edge in an app; a contained provider's box
// is the edge, so the sidebar is placed in it, as tall as it is.
contained ? "absolute h-full" : "fixed h-svh",
// 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 group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
// Says which way the next press goes: whether the rail is expanded, or
// on a phone whether its sheet is showing.
aria-expanded={isMobile ? openMobile : open}
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-[translate,background-color] duration-(--duration-fast) ease-(--ease-emphasized) 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
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
// The app's one <main>; under a contained provider the page around the box
// already has its main, so the inset is a plain region. A <div> takes every
// prop a <main> does, ref included; the cast only says so to the compiler.
const Inset = (React.useContext(SidebarContext)?.contained ? "div" : "main") as "main"
return (
<Inset
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:overflow-clip md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:ring-1 md:peer-data-[variant=inset]:ring-border md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", 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 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 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 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 p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"type-eyebrow flex h-8 shrink-0 items-center rounded-md px-2 outline-hidden transition-[margin,opacity] duration-(--duration-base) ease-(--ease-emphasized) group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-ring [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) focus-ring group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
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-0", 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", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button relative flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm transition-[background-color,color,width,height,padding] duration-(--duration-fast) ease-(--ease-standard) focus-ring group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
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,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
// A phone's sheet shows every label, so its buttons carry no tooltip —
// one opened on focus would take the Escape that should close the sheet.
render: !tooltip || isMobile ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip || isMobile) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed"}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) focus-ring group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground 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-sidebar-accent hover:text-sidebar-accent-foreground md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute end-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground 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 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
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-s border-sidebar-border 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,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"relative flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground transition-[background-color,color] duration-(--duration-fast) ease-(--ease-standard) focus-ring group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground 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-label data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}