Sidebar
A collapsible app navigation rail with grouped menus, submenus, and an account card.
npx shadcn@latest add @velaui/sidebarpnpm dlx velaui add @velaui/sidebaryarn dlx shadcn@latest add @velaui/sidebarbunx --bun shadcn@latest add @velaui/sidebarveni add sidebarInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/sidebarpnpm dlx velaui add @velaui/sidebaryarn dlx shadcn@latest add @velaui/sidebarbunx --bun shadcn@latest add @velaui/sidebarExamples
- App layout
- Sidebar floating
- Sidebar default
Usage
Props
Sidebar
| Prop | Type | Default |
|---|---|---|
collapsible | "none" | "icon" | "offcanvas" | "offcanvas" |
side | "left" | "right" | "left" |
variant | "sidebar" | "floating" | "inset" | "sidebar" |
Takes these and every prop of SidebarProps.
SidebarContent
Takes every prop of SidebarContentProps.
SidebarFooter
Takes every prop of SidebarFooterProps.
SidebarGroup
Takes every prop of SidebarGroupProps.
SidebarGroupAction
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
Takes these and every prop of SidebarGroupActionProps.
SidebarGroupContent
Takes every prop of SidebarGroupContentProps.
SidebarGroupLabel
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
Takes these and every prop of SidebarGroupLabelProps.
SidebarHeader
Takes every prop of SidebarHeaderProps.
SidebarInput
| Prop | Type | Default |
|---|---|---|
size | "sm" | "md" | "lg" | — |
variant | "fill" | "underline" | "outlined" | — |
Takes these and every prop of SidebarInputProps.
SidebarInset
Takes every prop of SidebarInsetProps.
SidebarMenu
Takes every prop of SidebarMenuProps.
SidebarMenuAction
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
showOnHover | boolean | false |
Takes these and every prop of SidebarMenuActionProps.
SidebarMenuBadge
Takes every prop of SidebarMenuBadgeProps.
SidebarMenuButton
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
isActive | boolean | false |
size | "sm" | "lg" | "default" | "default" |
tooltip | string | (import("/app/node_modules/.bun/@radix-ui+react-tooltip@1.2.10+ae2b4604ef263bc9/node_modules/@radix-ui/react-tooltip/dist/index").TooltipContentProps & React.RefAttributes<HTMLDivElement> & VariantProps<(props?: ({ variant?: "neutral" | "accent" | null | undefined; } & import("/app/node_modules/.bun/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/types").ClassProp) | undefined) => string>) | — |
variant | "outline" | "default" | "default" |
Takes these and every prop of SidebarMenuButtonProps.
SidebarMenuItem
Takes every prop of SidebarMenuItemProps.
SidebarMenuSkeleton
| Prop | Type | Default |
|---|---|---|
showIcon | boolean | false |
Takes these and every prop of SidebarMenuSkeletonProps.
SidebarMenuSub
Takes every prop of SidebarMenuSubProps.
SidebarMenuSubButton
| Prop | Type | Default |
|---|---|---|
asChild | boolean | false |
isActive | boolean | false |
size | "sm" | "md" | "md" |
Takes these and every prop of SidebarMenuSubButtonProps.
SidebarMenuSubItem
Takes every prop of SidebarMenuSubItemProps.
SidebarProvider
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | true |
onOpenChange | (open: boolean) => void | — |
open | boolean | — |
Takes these and every prop of SidebarProviderProps.
SidebarRail
Takes every prop of SidebarRailProps.
SidebarSeparator
| Prop | Type | Default |
|---|---|---|
emphasis | "high" | "light" | — |
thickness | "default" | "bold" | — |
variant | "solid" | "dash" | — |
Takes these and every prop of SidebarSeparatorProps.
SidebarTrigger
| Prop | Type | Default |
|---|---|---|
asChild | boolean | — |
size | "sm" | "md" | "lg" | "xs" | "xl" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg" | "icon-xl" | — |
variant | "outline" | "link" | "destructive" | "ghost" | "primary" | "secondary" | — |
Takes these and every prop of SidebarTriggerProps.
Sidebar
| Prop | Type | Default |
|---|---|---|
collapsible | "none" | "icon" | "offcanvas" | "offcanvas" |
side | "left" | "right" | "left" |
variant | "sidebar" | "floating" | "inset" | "sidebar" |
Takes these and every prop of SidebarProps.
SidebarContent
Takes every prop of SidebarContentProps.
SidebarFooter
Takes every prop of SidebarFooterProps.
SidebarGroup
Takes every prop of SidebarGroupProps.
SidebarGroupAction
Takes every prop of SidebarGroupActionProps.
SidebarGroupContent
Takes every prop of SidebarGroupContentProps.
SidebarGroupLabel
Takes every prop of SidebarGroupLabelProps.
SidebarHeader
Takes every prop of SidebarHeaderProps.
SidebarInput
| Prop | Type | Default |
|---|---|---|
size | "sm" | "md" | "lg" | — |
variant | "underline" | "fill" | "outlined" | — |
Takes these and every prop of SidebarInputProps.
SidebarInset
Takes every prop of SidebarInsetProps.
SidebarMenu
Takes every prop of SidebarMenuProps.
SidebarMenuAction
| Prop | Type | Default |
|---|---|---|
showOnHover | boolean | false |
Takes these and every prop of SidebarMenuActionProps.
SidebarMenuBadge
Takes every prop of SidebarMenuBadgeProps.
SidebarMenuButton
| Prop | Type | Default |
|---|---|---|
isActive | boolean | false |
size | "sm" | "lg" | "default" | "default" |
tooltip | string | (import("/app/node_modules/.bun/@base-ui+react@1.6.0+df7f6a806acd27ca/node_modules/@base-ui/react/index").TooltipPopupProps & Pick<import("/app/node_modules/.bun/@base-ui+react@1.6.0+df7f6a806acd27ca/node_modules/@base-ui/react/index").TooltipPositionerProps, "side" | "align" | "alignOffset" | "arrowPadding"> & VariantProps<(props?: ({ variant?: "neutral" | "accent" | null | undefined; } & import("/app/node_modules/.bun/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/types").ClassProp) | undefined) => string> & { sideOffset?: number; }) | — |
variant | "outline" | "default" | "default" |
Takes these and every prop of SidebarMenuButtonProps.
SidebarMenuItem
Takes every prop of SidebarMenuItemProps.
SidebarMenuSkeleton
| Prop | Type | Default |
|---|---|---|
showIcon | boolean | false |
Takes these and every prop of SidebarMenuSkeletonProps.
SidebarMenuSub
Takes every prop of SidebarMenuSubProps.
SidebarMenuSubButton
| Prop | Type | Default |
|---|---|---|
isActive | boolean | false |
size | "sm" | "md" | "md" |
Takes these and every prop of SidebarMenuSubButtonProps.
SidebarMenuSubItem
Takes every prop of SidebarMenuSubItemProps.
SidebarProvider
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | true |
onOpenChange | (open: boolean) => void | — |
open | boolean | — |
Takes these and every prop of SidebarProviderProps.
SidebarRail
Takes every prop of SidebarRailProps.
SidebarSeparator
| Prop | Type | Default |
|---|---|---|
emphasis | "high" | "light" | — |
thickness | "default" | "bold" | — |
variant | "solid" | "dash" | — |
Takes these and every prop of SidebarSeparatorProps.
SidebarTrigger
| Prop | Type | Default |
|---|---|---|
size | "sm" | "md" | "lg" | "xs" | "xl" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg" | "icon-xl" | — |
variant | "link" | "outline" | "destructive" | "ghost" | "primary" | "secondary" | — |
Takes these and every prop of SidebarTriggerProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1
Source
components/ui/sidebar.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Dialog as DialogPrimitive, Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronLeft } from "@/components/icons/chevron-left";
import { ChevronRight } from "@/components/icons/chevron-right";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
/** Full and mini rail widths are drawn at 292px / 80px in the design. */
const SIDEBAR_WIDTH = "292px";
const SIDEBAR_WIDTH_MOBILE = "292px";
const SIDEBAR_WIDTH_ICON = "80px";
/**
* The mini rail's square: every collapsed row, logo and avatar slot is this
* size, centred by an equal gutter on each side. Keep the two in one equation
* — changing the rail or the square re-derives the gutter for every slot.
*/
const SIDEBAR_ITEM = "40px";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
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;
}
export type SidebarProviderProps = React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: SidebarProviderProps) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
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);
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
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]);
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 value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
"--sidebar-item": SIDEBAR_ITEM,
"--sidebar-gutter":
"calc((var(--sidebar-width-icon) - var(--sidebar-item)) / 2)",
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full bg-bg-main",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext>
);
}
/** The panel body — the surface every layout variant renders its children into. */
const SIDEBAR_PANEL = "flex size-full flex-col bg-bg-main text-text-strong";
export type SidebarProps = React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
};
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: SidebarProps) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
data-state="expanded"
className={cn(
"group/sidebar w-(--sidebar-width)",
SIDEBAR_PANEL,
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<DialogPrimitive.Root open={openMobile} onOpenChange={setOpenMobile}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="sidebar-overlay"
className="fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
/>
<DialogPrimitive.Content
data-slot="sidebar"
data-mobile="true"
data-side={side}
data-state={state}
className={cn(
"group/sidebar fixed inset-y-0 z-50 w-(--sidebar-width) outline-none duration-200 data-open:animate-in data-closed:animate-out",
side === "left"
? "left-0 data-open:slide-in-from-left data-closed:slide-out-to-left"
: "right-0 data-open:slide-in-from-right data-closed:slide-out-to-right",
className,
)}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
{...props}
>
<DialogPrimitive.Title className="sr-only">
Sidebar
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Displays the mobile sidebar.
</DialogPrimitive.Description>
<div className={SIDEBAR_PANEL}>{children}</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
return (
<div
className="group/sidebar peer hidden text-text-strong md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* Holds the space the fixed panel occupies in the page flow. */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]/sidebar:w-0",
"group-data-[side=right]/sidebar:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]/sidebar:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]/sidebar:right-[calc(var(--sidebar-width)*-1)] md:flex",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: // The design's stroke on the panel has no per-side override, so
// it runs on all four edges, not just the trailing one.
"group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon) border border-border-default",
className,
)}
{...props}
>
<div
data-slot="sidebar-inner"
className={cn(
SIDEBAR_PANEL,
"group-data-[variant=floating]/sidebar:rounded-xl group-data-[variant=floating]/sidebar:border group-data-[variant=floating]/sidebar:border-border-default",
)}
>
{children}
</div>
</div>
</div>
);
}
export type SidebarTriggerProps = React.ComponentProps<typeof Button>;
/**
* The 24px collapse pill. The design pins it half-outside the panel's trailing
* edge, which is a placement decision for the layout — pass it as `className`.
*
* Position it with insets or margins, never a transform: Button presses with
* `active:translate-y-px`, and that active utility replaces the whole
* `translate` property, so a centring `-translate-y-1/2` would drop out on
* mousedown and the pill would jump by half its height.
*/
function SidebarTrigger({
className,
onClick,
...props
}: SidebarTriggerProps) {
const { state, toggleSidebar } = useSidebar();
// The design swaps to a distinct chevron-right glyph when collapsed rather
// than rotating chevron-left — read directly off the two "Button/Icon"
// instances in the Type=Full / Type=Mini examples (componentId 2185:777
// "chevron-left" vs 2185:781 "chevron-right").
const ChevronIcon = state === "collapsed" ? ChevronRight : ChevronLeft;
return (
<Button
data-slot="sidebar-trigger"
variant="outline"
size="icon-xs"
className={cn(
"size-6 rounded-full bg-bg-surface-1 backdrop-blur-[40px] [&_svg:not([class*='size-'])]:size-4",
className,
)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<ChevronIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export type SidebarRailProps = React.ComponentProps<"button">;
function SidebarRail({ className, ...props }: SidebarRailProps) {
const { toggleSidebar } = useSidebar();
return (
<button
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 -translate-x-1/2 transition-all ease-linear group-data-[side=left]/sidebar:-right-4 group-data-[side=right]/sidebar:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-border-hover sm:flex",
"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]/sidebar:translate-x-0 group-data-[collapsible=offcanvas]/sidebar:after:left-full hover:group-data-[collapsible=offcanvas]/sidebar:bg-bg-surface-1",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
export type SidebarInsetProps = React.ComponentProps<"main">;
function SidebarInset({ className, ...props }: SidebarInsetProps) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-bg-main md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
export type SidebarInputProps = React.ComponentProps<typeof Input>;
function SidebarInput({
className,
...props
}: SidebarInputProps) {
return (
<Input
data-slot="sidebar-input"
className={cn("w-full bg-bg-main", className)}
{...props}
/>
);
}
export type SidebarHeaderProps = React.ComponentProps<"div">;
/** 80px tall slot for the product logo, divided from the body by a hairline. */
function SidebarHeader({ className, ...props }: SidebarHeaderProps) {
return (
<div
data-slot="sidebar-header"
className={cn(
"relative flex h-26.5 shrink-0 items-center gap-2 border-b border-border-default px-4 py-6 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarFooterProps = React.ComponentProps<"div">;
/** The account slot: a wider 20px vertical rhythm than the rest of the panel. */
function SidebarFooter({ className, ...props }: SidebarFooterProps) {
return (
<div
data-slot="sidebar-footer"
className={cn(
"flex shrink-0 flex-col gap-2 border-t border-border-default px-4 py-3 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarSeparatorProps = React.ComponentProps<typeof Separator>;
function SidebarSeparator({
className,
...props
}: SidebarSeparatorProps) {
return (
<Separator
data-slot="sidebar-separator"
emphasis={"light"}
className={cn("my-1 w-full", className)}
{...props}
/>
);
}
export type SidebarContentProps = React.ComponentProps<"div">;
function SidebarContent({ className, ...props }: SidebarContentProps) {
return (
<div
data-slot="sidebar-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto py-2 group-data-[collapsible=icon]/sidebar:overflow-hidden",
className,
)}
{...props}
/>
);
}
export type SidebarGroupProps = React.ComponentProps<"div">;
/** Menu rows inset 16px; the mini rail centres its 40px squares at 20px. */
function SidebarGroup({ className, ...props }: SidebarGroupProps) {
return (
<div
data-slot="sidebar-group"
className={cn(
"relative flex w-full min-w-0 flex-col px-4 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarGroupLabelProps = React.ComponentProps<"div"> & { asChild?: boolean };
function SidebarGroupLabel({
className,
asChild = false,
...props
}: SidebarGroupLabelProps) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="sidebar-group-label"
className={cn(
// Sections sit 24px from the panel edge — 8px further out than the rows
// they label, hence the negative inset against SidebarGroup's px-4.
"flex shrink-0 items-center px-2 h-8 text-sm leading-6 font-medium tracking-[-0.2px] text-text-note ring-ring-neutral-shadow outline-none transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]/sidebar:pointer-events-none group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0 focus-visible:ring-3 [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
/>
);
}
export type SidebarGroupActionProps = React.ComponentProps<"button"> & { asChild?: boolean };
function SidebarGroupAction({
className,
asChild = false,
...props
}: SidebarGroupActionProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-group-action"
className={cn(
"absolute top-2.5 right-4 flex aspect-square w-6 items-center justify-center rounded-lg p-0 text-icon-muted ring-ring-neutral-shadow outline-none transition-transform group-data-[collapsible=icon]/sidebar:hidden after:absolute after:-inset-2 hover:bg-bg-surface-2 hover:text-icon-strong focus-visible:ring-3 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
/>
);
}
export type SidebarGroupContentProps = React.ComponentProps<"div">;
function SidebarGroupContent({
className,
...props
}: SidebarGroupContentProps) {
return (
<div
data-slot="sidebar-group-content"
className={cn("w-full text-base", className)}
{...props}
/>
);
}
export type SidebarMenuProps = React.ComponentProps<"ul">;
function SidebarMenu({ className, ...props }: SidebarMenuProps) {
return (
<ul
data-slot="sidebar-menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
export type SidebarMenuItemProps = React.ComponentProps<"li">;
function SidebarMenuItem({ className, ...props }: SidebarMenuItemProps) {
return (
<li
data-slot="sidebar-menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
// Hover and selected are the same 5% surface in the design — selection is
// carried by the icon and the active sub-item rail, not by a second colour.
//
// The mini rail keeps only the row's leading child — icon, avatar, whatever
// it is — and hides the rest. Hiding by element type instead would take out
// any leading element that happens to be a span (Avatar's root is one).
//
// Focus draws a 1px ring-neutral-outline stroke plus a bg-main fill and a
// ring-neutral-shadow ring in the design. The stroke is an inset ring, not a
// border: a border would take 2px out of the 40px mini square, leaving 22px
// for a 24px icon.
"peer/menu-button group/menu-button flex w-full items-center gap-4 overflow-hidden rounded-lg p-2 text-left leading-[1.4] font-medium text-text-strong ring-ring-neutral-shadow outline-none transition-[width,height,padding] duration-200 ease-linear group-has-data-[slot=sidebar-menu-action]/menu-item:pr-10 group-has-data-[slot=sidebar-menu-badge]/menu-item:pr-12 group-data-[collapsible=icon]/sidebar:size-(--sidebar-item)! group-data-[collapsible=icon]/sidebar:p-2! group-data-[collapsible=icon]/sidebar:[&>*:not(:first-child)]:hidden hover:bg-blue-50 hover:text-blue-600 focus-visible:bg-bg-main focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow focus-visible:inset-ring focus-visible:inset-ring-ring-neutral-outline active:bg-blue-50 disabled:pointer-events-none disabled:text-text-disabled disabled:[&_svg]:text-icon-disabled aria-disabled:pointer-events-none aria-disabled:text-text-disabled data-open:hover:bg-blue-50 data-active:bg-blue-50 data-active:font-semibold data-active:text-blue-600 data-active:[&_svg]:text-blue-600 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "",
outline:
"bg-bg-main shadow-[0_0_0_1px_var(--border-default)] hover:bg-blue-50 hover:shadow-[0_0_0_1px_var(--border-hover)]",
},
size: {
default: "text-base",
sm: "text-sm [&_svg:not([class*='size-'])]:size-5",
// The mini square holds whatever leads the row: the 40px workspace logo
// fills it (p-0), the design's 32px account avatar sits (40 - 32) / 2 =
// 4px in (p-1). Centred by padding, never justify-center — padding
// animates with the width, justify-center snaps before the rail shrinks.
lg: "h-14 text-base group-data-[collapsible=icon]/sidebar:p-0! group-data-[collapsible=icon]/sidebar:has-[>[data-slot=avatar][data-size=sm]]:p-1!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type SidebarMenuButtonProps = React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>;
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: SidebarMenuButtonProps) {
const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
export type SidebarMenuActionProps = React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
};
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: SidebarMenuActionProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-menu-action"
className={cn(
"absolute top-1/2 right-2 flex aspect-square w-6 -translate-y-1/2 items-center justify-center rounded-lg p-0 text-icon-muted ring-ring-neutral-shadow outline-none transition-transform group-data-[collapsible=icon]/sidebar:hidden peer-hover/menu-button:text-icon-strong after:absolute after:-inset-2 hover:bg-bg-surface-2 hover:text-icon-strong focus-visible:ring-3 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-icon-strong aria-expanded:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
export type SidebarMenuBadgeProps = React.ComponentProps<"div">;
/** The counter pill the design parks at the trailing edge of a row. */
function SidebarMenuBadge({
className,
...props
}: SidebarMenuBadgeProps) {
return (
<div
data-slot="sidebar-menu-badge"
className={cn(
"pointer-events-none absolute top-1/2 right-2 flex min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-bg-surface-2 px-1 py-0.5 text-xs leading-4 font-medium text-text-strong tabular-nums select-none group-data-[collapsible=icon]/sidebar:hidden",
className,
)}
{...props}
/>
);
}
export type SidebarMenuSkeletonProps = React.ComponentProps<"div"> & {
showIcon?: boolean;
};
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: SidebarMenuSkeletonProps) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
className={cn("flex h-10 items-center gap-4 rounded-xl p-2", className)}
{...props}
>
{showIcon && (
<Skeleton
data-slot="sidebar-menu-skeleton-icon"
className="size-6 shrink-0 rounded-md bg-bg-surface-2"
/>
)}
<Skeleton
data-slot="sidebar-menu-skeleton-text"
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-bg-surface-2"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
export type SidebarMenuSubProps = React.ComponentProps<"ul">;
function SidebarMenuSub({ className, ...props }: SidebarMenuSubProps) {
return (
<ul
data-slot="sidebar-menu-sub"
className={cn(
"flex min-w-0 flex-col px-2 py-1 group-data-[collapsible=icon]/sidebar:hidden",
className,
)}
{...props}
/>
);
}
export type SidebarMenuSubItemProps = React.ComponentProps<"li">;
function SidebarMenuSubItem({
className,
...props
}: SidebarMenuSubItemProps) {
return (
<li
data-slot="sidebar-menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
export type SidebarMenuSubButtonProps = React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
};
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: SidebarMenuSubButtonProps) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
// The leading indicator is a 6px dot ("Bullet" in the design) rendered
// as a pseudo-element, not a real flex child — so the label's own
// indent has to reserve the room for it: 18px of panel to the dot,
// its own 6px, then the 16px gap to the label. That reserved total is
// pl-10 (40px); the dot itself is positioned independently via
// `before:left`, which ignores padding.
//
// Focus draws the same border-transparent → border-ring-neutral-outline
// reveal as the top-level menu button (rule 11): reserved at rest so
// the row's h=40 never shifts.
"relative flex h-10 min-w-0 items-center gap-4 overflow-hidden rounded-xl border border-transparent py-2 pr-4 pl-10 leading-[1.4] font-medium text-text-muted ring-ring-neutral-shadow outline-none",
"before:absolute before:top-1/2 before:left-[18px] before:size-1.5 before:-translate-y-1/2 before:rounded-full before:bg-sky-200",
"hover:bg-bg-surface-1 focus-visible:border-ring-neutral-outline focus-visible:bg-bg-main focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow active:bg-bg-surface-1",
"aria-disabled:pointer-events-none aria-disabled:text-text-disabled aria-disabled:before:bg-text-disabled",
"data-[size=md]:text-base data-[size=sm]:text-sm",
"data-active:font-semibold data-active:text-text-strong data-active:before:bg-blue-500",
"[&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
sidebarMenuButtonVariants,
useSidebar,
};components/ui/sidebar.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
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 { ChevronLeft } from "@/components/icons/chevron-left";
import { ChevronRight } from "@/components/icons/chevron-right";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
/** Full and mini rail widths are drawn at 292px / 80px in the design. */
const SIDEBAR_WIDTH = "292px";
const SIDEBAR_WIDTH_MOBILE = "292px";
const SIDEBAR_WIDTH_ICON = "80px";
/**
* The mini rail's square: every collapsed row, logo and avatar slot is this
* size, centred by an equal gutter on each side. Keep the two in one equation
* — changing the rail or the square re-derives the gutter for every slot.
*/
const SIDEBAR_ITEM = "40px";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
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;
}
export type SidebarProviderProps = React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: SidebarProviderProps) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
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);
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
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]);
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 value={contextValue}>
<TooltipProvider delay={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
"--sidebar-item": SIDEBAR_ITEM,
"--sidebar-gutter":
"calc((var(--sidebar-width-icon) - var(--sidebar-item)) / 2)",
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full bg-bg-main",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext>
);
}
/** The panel body — the surface every layout variant renders its children into. */
const SIDEBAR_PANEL = "flex size-full flex-col bg-bg-main text-text-strong";
export type SidebarProps = React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
};
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: SidebarProps) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
data-state="expanded"
className={cn(
"group/sidebar w-(--sidebar-width)",
SIDEBAR_PANEL,
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<DialogPrimitive.Root open={openMobile} onOpenChange={setOpenMobile}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
data-slot="sidebar-overlay"
className="fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
/>
<DialogPrimitive.Popup
data-slot="sidebar"
data-mobile="true"
data-side={side}
data-state={state}
className={cn(
"group/sidebar fixed inset-y-0 z-50 w-(--sidebar-width) outline-none duration-200 data-open:animate-in data-closed:animate-out",
side === "left"
? "left-0 data-open:slide-in-from-left data-closed:slide-out-to-left"
: "right-0 data-open:slide-in-from-right data-closed:slide-out-to-right",
className,
)}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
{...props}
>
<DialogPrimitive.Title className="sr-only">
Sidebar
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Displays the mobile sidebar.
</DialogPrimitive.Description>
<div className={SIDEBAR_PANEL}>{children}</div>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
return (
<div
className="group/sidebar peer hidden text-text-strong md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* Holds the space the fixed panel occupies in the page flow. */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]/sidebar:w-0",
"group-data-[side=right]/sidebar:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]/sidebar:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]/sidebar:right-[calc(var(--sidebar-width)*-1)] md:flex",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]/sidebar:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: // The design's stroke on the panel has no per-side override, so
// it runs on all four edges, not just the trailing one.
"group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon) border border-border-default",
className,
)}
{...props}
>
<div
data-slot="sidebar-inner"
className={cn(
SIDEBAR_PANEL,
"group-data-[variant=floating]/sidebar:rounded-xl group-data-[variant=floating]/sidebar:border group-data-[variant=floating]/sidebar:border-border-default",
)}
>
{children}
</div>
</div>
</div>
);
}
export type SidebarTriggerProps = React.ComponentProps<typeof Button>;
/**
* The 24px collapse pill. The design pins it half-outside the panel's trailing
* edge, which is a placement decision for the layout — pass it as `className`.
*
* Position it with insets or margins, never a transform: Button presses with
* `active:translate-y-px`, and that active utility replaces the whole
* `translate` property, so a centring `-translate-y-1/2` would drop out on
* mousedown and the pill would jump by half its height.
*/
function SidebarTrigger({
className,
onClick,
...props
}: SidebarTriggerProps) {
const { state, toggleSidebar } = useSidebar();
// The design swaps to a distinct chevron-right glyph when collapsed rather
// than rotating chevron-left — read directly off the two "Button/Icon"
// instances in the Type=Full / Type=Mini examples (componentId 2185:777
// "chevron-left" vs 2185:781 "chevron-right").
const ChevronIcon = state === "collapsed" ? ChevronRight : ChevronLeft;
return (
<Button
data-slot="sidebar-trigger"
variant="outline"
size="icon-xs"
className={cn(
"size-6 rounded-full bg-bg-surface-1 backdrop-blur-[40px] [&_svg:not([class*='size-'])]:size-4",
className,
)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<ChevronIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
export type SidebarRailProps = React.ComponentProps<"button">;
function SidebarRail({ className, ...props }: SidebarRailProps) {
const { toggleSidebar } = useSidebar();
return (
<button
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 -translate-x-1/2 transition-all ease-linear group-data-[side=left]/sidebar:-right-4 group-data-[side=right]/sidebar:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-border-hover sm:flex",
"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]/sidebar:translate-x-0 group-data-[collapsible=offcanvas]/sidebar:after:left-full hover:group-data-[collapsible=offcanvas]/sidebar:bg-bg-surface-1",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
export type SidebarInsetProps = React.ComponentProps<"main">;
function SidebarInset({ className, ...props }: SidebarInsetProps) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-bg-main md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
export type SidebarInputProps = React.ComponentProps<typeof Input>;
function SidebarInput({
className,
...props
}: SidebarInputProps) {
return (
<Input
data-slot="sidebar-input"
className={cn("w-full bg-bg-main", className)}
{...props}
/>
);
}
export type SidebarHeaderProps = React.ComponentProps<"div">;
/** 80px tall slot for the product logo, divided from the body by a hairline. */
function SidebarHeader({ className, ...props }: SidebarHeaderProps) {
return (
<div
data-slot="sidebar-header"
className={cn(
"relative flex h-26.5 shrink-0 items-center gap-2 border-b border-border-default px-4 py-6 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarFooterProps = React.ComponentProps<"div">;
/** The account slot: a wider 20px vertical rhythm than the rest of the panel. */
function SidebarFooter({ className, ...props }: SidebarFooterProps) {
return (
<div
data-slot="sidebar-footer"
className={cn(
"flex shrink-0 flex-col gap-2 border-t border-border-default px-4 py-3 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarSeparatorProps = React.ComponentProps<typeof Separator>;
function SidebarSeparator({
className,
...props
}: SidebarSeparatorProps) {
return (
<Separator
data-slot="sidebar-separator"
emphasis={"light"}
className={cn("my-1 w-full", className)}
{...props}
/>
);
}
export type SidebarContentProps = React.ComponentProps<"div">;
function SidebarContent({ className, ...props }: SidebarContentProps) {
return (
<div
data-slot="sidebar-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto py-2 group-data-[collapsible=icon]/sidebar:overflow-hidden",
className,
)}
{...props}
/>
);
}
export type SidebarGroupProps = React.ComponentProps<"div">;
/** Menu rows inset 16px; the mini rail centres its 40px squares at 20px. */
function SidebarGroup({ className, ...props }: SidebarGroupProps) {
return (
<div
data-slot="sidebar-group"
className={cn(
"relative flex w-full min-w-0 flex-col px-4 group-data-[collapsible=icon]/sidebar:px-(--sidebar-gutter) transition-[padding] duration-200 ease-linear",
className,
)}
{...props}
/>
);
}
export type SidebarGroupLabelProps = useRender.ComponentProps<"div"> & React.ComponentProps<"div">;
function SidebarGroupLabel({
className,
render,
...props
}: SidebarGroupLabelProps) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
// Sections sit 24px from the panel edge — 8px further out than the rows
// they label, hence the negative inset against SidebarGroup's px-4.
"flex shrink-0 items-center px-2 h-8 text-sm leading-6 font-medium tracking-[-0.2px] text-text-note ring-ring-neutral-shadow outline-none transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]/sidebar:pointer-events-none group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0 focus-visible:ring-3 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
},
});
}
export type SidebarGroupActionProps = useRender.ComponentProps<"button"> & React.ComponentProps<"button">;
function SidebarGroupAction({
className,
render,
...props
}: SidebarGroupActionProps) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-2.5 right-4 flex aspect-square w-6 items-center justify-center rounded-lg p-0 text-icon-muted ring-ring-neutral-shadow outline-none transition-transform group-data-[collapsible=icon]/sidebar:hidden after:absolute after:-inset-2 hover:bg-bg-surface-2 hover:text-icon-strong focus-visible:ring-3 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
},
});
}
export type SidebarGroupContentProps = React.ComponentProps<"div">;
function SidebarGroupContent({
className,
...props
}: SidebarGroupContentProps) {
return (
<div
data-slot="sidebar-group-content"
className={cn("w-full text-base", className)}
{...props}
/>
);
}
export type SidebarMenuProps = React.ComponentProps<"ul">;
function SidebarMenu({ className, ...props }: SidebarMenuProps) {
return (
<ul
data-slot="sidebar-menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
export type SidebarMenuItemProps = React.ComponentProps<"li">;
function SidebarMenuItem({ className, ...props }: SidebarMenuItemProps) {
return (
<li
data-slot="sidebar-menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
// Hover and selected are the same 5% surface in the design — selection is
// carried by the icon and the active sub-item rail, not by a second colour.
//
// The mini rail keeps only the row's leading child — icon, avatar, whatever
// it is — and hides the rest. Hiding by element type instead would take out
// any leading element that happens to be a span (Avatar's root is one).
//
// Focus draws a 1px ring-neutral-outline stroke plus a bg-main fill and a
// ring-neutral-shadow ring in the design. The stroke is an inset ring, not a
// border: a border would take 2px out of the 40px mini square, leaving 22px
// for a 24px icon.
//
// Composed as a menu trigger, the base flavour marks the open row with
// data-popup-open (Radix used data-state="open"), so the hover surface below
// carries both spellings and the row keeps its fill while a menu is open on
// it.
"peer/menu-button group/menu-button flex w-full items-center gap-4 overflow-hidden rounded-lg p-2 text-left leading-[1.4] font-medium text-text-strong ring-ring-neutral-shadow outline-none transition-[width,height,padding] duration-200 ease-linear group-has-data-[slot=sidebar-menu-action]/menu-item:pr-10 group-has-data-[slot=sidebar-menu-badge]/menu-item:pr-12 group-data-[collapsible=icon]/sidebar:size-(--sidebar-item)! group-data-[collapsible=icon]/sidebar:p-2! group-data-[collapsible=icon]/sidebar:[&>*:not(:first-child)]:hidden hover:bg-blue-50 hover:text-blue-600 focus-visible:bg-bg-main focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow focus-visible:inset-ring focus-visible:inset-ring-ring-neutral-outline active:bg-blue-50 disabled:pointer-events-none disabled:text-text-disabled disabled:[&_svg]:text-icon-disabled aria-disabled:pointer-events-none aria-disabled:text-text-disabled data-open:hover:bg-blue-50 data-popup-open:hover:bg-bg-surface-1 data-active:bg-blue-50 data-active:font-semibold data-active:text-blue-600 data-active:[&_svg]:text-blue-600 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "",
outline:
"bg-bg-main shadow-[0_0_0_1px_var(--border-default)] hover:bg-blue-50 hover:shadow-[0_0_0_1px_var(--border-hover)]",
},
size: {
default: "text-base",
sm: "text-sm [&_svg:not([class*='size-'])]:size-5",
// The mini square holds whatever leads the row: the 40px workspace logo
// fills it (p-0), the design's 32px account avatar sits (40 - 32) / 2 =
// 4px in (p-1). Centred by padding, never justify-center — padding
// animates with the width, justify-center snaps before the rail shrinks.
lg: "h-14 text-base group-data-[collapsible=icon]/sidebar:p-0! group-data-[collapsible=icon]/sidebar:has-[>[data-slot=avatar][data-size=sm]]:p-1!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type SidebarMenuButtonProps = useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>;
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: SidebarMenuButtonProps) {
const { isMobile, state } = useSidebar();
const button = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
// Tooltip.Trigger takes `disabled` for itself (it only mutes the tooltip), so the button it
// renders is given the disabled state directly; Radix's Slot forwards it to the button.
render: !tooltip ? (
render
) : (
<TooltipTrigger render={render ?? <button disabled={props.disabled} />} />
),
state: {
slot: "sidebar-menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
{button}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
export type SidebarMenuActionProps = useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
};
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: SidebarMenuActionProps) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1/2 right-2 flex aspect-square w-6 -translate-y-1/2 items-center justify-center rounded-lg p-0 text-icon-muted ring-ring-neutral-shadow outline-none transition-transform group-data-[collapsible=icon]/sidebar:hidden peer-hover/menu-button:text-icon-strong after:absolute after:-inset-2 hover:bg-bg-surface-2 hover:text-icon-strong focus-visible:ring-3 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-icon-strong aria-expanded:opacity-100 md:opacity-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
},
});
}
export type SidebarMenuBadgeProps = React.ComponentProps<"div">;
/** The counter pill the design parks at the trailing edge of a row. */
function SidebarMenuBadge({
className,
...props
}: SidebarMenuBadgeProps) {
return (
<div
data-slot="sidebar-menu-badge"
className={cn(
"pointer-events-none absolute top-1/2 right-2 flex min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-bg-surface-2 px-1 py-0.5 text-xs leading-4 font-medium text-text-strong tabular-nums select-none group-data-[collapsible=icon]/sidebar:hidden",
className,
)}
{...props}
/>
);
}
export type SidebarMenuSkeletonProps = React.ComponentProps<"div"> & {
showIcon?: boolean;
};
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: SidebarMenuSkeletonProps) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
className={cn("flex h-10 items-center gap-4 rounded-xl p-2", className)}
{...props}
>
{showIcon && (
<Skeleton
data-slot="sidebar-menu-skeleton-icon"
className="size-6 shrink-0 rounded-md bg-bg-surface-2"
/>
)}
<Skeleton
data-slot="sidebar-menu-skeleton-text"
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-bg-surface-2"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
export type SidebarMenuSubProps = React.ComponentProps<"ul">;
function SidebarMenuSub({ className, ...props }: SidebarMenuSubProps) {
return (
<ul
data-slot="sidebar-menu-sub"
className={cn(
"flex min-w-0 flex-col px-2 py-1 group-data-[collapsible=icon]/sidebar:hidden",
className,
)}
{...props}
/>
);
}
export type SidebarMenuSubItemProps = React.ComponentProps<"li">;
function SidebarMenuSubItem({
className,
...props
}: SidebarMenuSubItemProps) {
return (
<li
data-slot="sidebar-menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
export type SidebarMenuSubButtonProps = useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
};
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: SidebarMenuSubButtonProps) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
// The leading indicator is a 6px dot ("Bullet" in the design) rendered
// as a pseudo-element, not a real flex child — so the label's own
// indent has to reserve the room for it: 18px of panel to the dot,
// its own 6px, then the 16px gap to the label. That reserved total is
// pl-10 (40px); the dot itself is positioned independently via
// `before:left`, which ignores padding.
//
// Focus draws the same border-transparent → border-ring-neutral-outline
// reveal as the top-level menu button (rule 11): reserved at rest so
// the row's h=40 never shifts.
"relative flex h-10 min-w-0 items-center gap-4 overflow-hidden rounded-xl border border-transparent py-2 pr-4 pl-10 leading-[1.4] font-medium text-text-muted ring-ring-neutral-shadow outline-none",
"before:absolute before:top-1/2 before:left-[18px] before:size-1.5 before:-translate-y-1/2 before:rounded-full before:bg-sky-200",
"hover:bg-bg-surface-1 focus-visible:border-ring-neutral-outline focus-visible:bg-bg-main focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow active:bg-bg-surface-1",
"aria-disabled:pointer-events-none aria-disabled:text-text-disabled aria-disabled:before:bg-text-disabled",
"data-[size=md]:text-base data-[size=sm]:text-sm",
"data-active:font-semibold data-active:text-text-strong data-active:before:bg-blue-500",
"[&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "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,
sidebarMenuButtonVariants,
useSidebar,
};