{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "sidebar", "title": "Sidebar", "description": "Responsive sidebar with desktop collapse modes, persistence, shortcut support, and mobile drawer behavior.", "dependencies": [ "@base-ui/react", "framer-motion" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/surface-context.json", "https://zeron-ui.vercel.app/r/surface-classes.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/button.json", "https://zeron-ui.vercel.app/r/scroll-area.json", "https://zeron-ui.vercel.app/r/mobile-drawer.json" ], "files": [ { "path": "src/components/ui/sidebar.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { useDirection } from \"@base-ui/react/direction-provider\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { useSurface, SurfaceProvider } from \"@/lib/surface-context\";\nimport { resolveSurface, surfaceClasses } from \"@/lib/surface-classes\";\nimport { useIcon } from \"@/lib/icon-context\";\nimport { Button } from \"@/components/ui/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { MobileDrawer } from \"@/components/ui/mobile-drawer\";\n\nexport const SIDEBAR_MOBILE_QUERY = \"(max-width: 1279px)\";\n\nexport type SidebarState = \"expanded\" | \"collapsed\";\nexport type SidebarVariant = \"sidebar\" | \"floating\";\nexport type SidebarCollapsible = \"offcanvas\" | \"icon\" | \"none\";\nexport type SidebarSide = \"start\" | \"end\";\n\nexport interface SidebarProviderProps {\n children: ReactNode;\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n defaultMobileOpen?: boolean;\n mobileOpen?: boolean;\n onMobileOpenChange?: (open: boolean) => void;\n persistenceKey?: string;\n keyboardShortcut?: string;\n}\n\nexport interface SidebarContextValue {\n state: SidebarState;\n open: boolean;\n setOpen: (open: boolean) => void;\n mobileOpen: boolean;\n setMobileOpen: (open: boolean) => void;\n isMobile: boolean;\n triggerRef: React.RefObject;\n /** Set the element that should regain focus after the next compact-drawer close. */\n setActiveTrigger: (trigger: HTMLElement | null) => void;\n toggle: () => void;\n closeMobile: () => void;\n}\n\nconst SidebarContext = createContext(null);\n\nfunction subscribeToSidebarBreakpoint(listener: () => void) {\n const media = window.matchMedia(SIDEBAR_MOBILE_QUERY);\n media.addEventListener(\"change\", listener);\n return () => media.removeEventListener(\"change\", listener);\n}\n\nfunction getSidebarBreakpointSnapshot() {\n return window.matchMedia(SIDEBAR_MOBILE_QUERY).matches;\n}\n\nfunction useSidebarMobile() {\n return useSyncExternalStore(subscribeToSidebarBreakpoint, getSidebarBreakpointSnapshot, () => false);\n}\n\nexport function useSidebar() {\n const context = useContext(SidebarContext);\n if (!context) throw new Error(\"useSidebar must be used within a SidebarProvider\");\n return context;\n}\n\nfunction isEditableTarget(target: EventTarget | null) {\n if (!(target instanceof HTMLElement)) return false;\n const tagName = target.tagName;\n return tagName === \"INPUT\" || tagName === \"TEXTAREA\" || tagName === \"SELECT\" || target.isContentEditable;\n}\n\nconst SidebarProvider = ({\n children,\n defaultOpen = true,\n open: openProp,\n onOpenChange,\n defaultMobileOpen = false,\n mobileOpen: mobileOpenProp,\n onMobileOpenChange,\n persistenceKey,\n keyboardShortcut,\n}: SidebarProviderProps) => {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const [internalMobileOpen, setInternalMobileOpen] = useState(defaultMobileOpen);\n const triggerRef = useRef(null);\n const finalFocusPreparedRef = useRef(false);\n const previousMobileOpenRef = useRef(defaultMobileOpen);\n const isMobile = useSidebarMobile();\n const open = openProp ?? internalOpen;\n const mobileOpen = mobileOpenProp ?? internalMobileOpen;\n const mobileOpenRef = useRef(mobileOpen);\n\n useEffect(() => {\n mobileOpenRef.current = mobileOpen;\n }, [mobileOpen]);\n\n const setOpen = useCallback(\n (nextOpen: boolean) => {\n if (openProp === undefined) setInternalOpen(nextOpen);\n onOpenChange?.(nextOpen);\n },\n [onOpenChange, openProp]\n );\n const setMobileOpen = useCallback(\n (nextOpen: boolean) => {\n if (nextOpen && !mobileOpenRef.current) {\n // A programmatic open cannot infer which element initiated it. Clear a\n // previous session's owner unless the caller explicitly supplied one\n // with setActiveTrigger() immediately before opening.\n if (!finalFocusPreparedRef.current) triggerRef.current = null;\n }\n if (mobileOpenProp === undefined) setInternalMobileOpen(nextOpen);\n onMobileOpenChange?.(nextOpen);\n },\n [mobileOpenProp, onMobileOpenChange]\n );\n const setActiveTrigger = useCallback((trigger: HTMLElement | null) => {\n triggerRef.current = trigger;\n finalFocusPreparedRef.current = true;\n }, []);\n const closeMobile = useCallback(() => setMobileOpen(false), [setMobileOpen]);\n const toggle = useCallback(() => {\n if (isMobile) setMobileOpen(!mobileOpen);\n else setOpen(!open);\n }, [isMobile, mobileOpen, open, setMobileOpen, setOpen]);\n\n useEffect(() => {\n const wasMobileOpen = previousMobileOpenRef.current;\n if (mobileOpen && !wasMobileOpen) {\n // Also handles consumers that control mobileOpen directly instead of\n // opening through setMobileOpen(). A direct open without an explicitly\n // prepared owner must never reuse a previous drawer session's trigger.\n if (!finalFocusPreparedRef.current) triggerRef.current = null;\n finalFocusPreparedRef.current = false;\n }\n previousMobileOpenRef.current = mobileOpen;\n }, [mobileOpen]);\n\n useEffect(() => {\n if (!persistenceKey || openProp !== undefined) return;\n try {\n const stored = window.localStorage.getItem(persistenceKey);\n if (stored === \"open\" || stored === \"closed\") setInternalOpen(stored === \"open\");\n } catch {\n // Storage is optional; the controlled/default state remains authoritative.\n }\n }, [openProp, persistenceKey]);\n\n useEffect(() => {\n if (!persistenceKey || openProp !== undefined) return;\n try {\n window.localStorage.setItem(persistenceKey, open ? \"open\" : \"closed\");\n } catch {\n // Best-effort persistence only.\n }\n }, [open, openProp, persistenceKey]);\n\n useEffect(() => {\n if (isMobile || !mobileOpen) return;\n setMobileOpen(false);\n const trigger = triggerRef.current;\n if (trigger?.isConnected) trigger.focus();\n }, [isMobile, mobileOpen, setMobileOpen]);\n\n useEffect(() => {\n if (!keyboardShortcut) return;\n const normalized = keyboardShortcut.toLowerCase();\n const match = normalized.match(/^mod\\+(.+)$/);\n if (!match) return;\n const key = match[1];\n const onKeyDown = (event: KeyboardEvent) => {\n if (isEditableTarget(event.target)) return;\n if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== key) return;\n event.preventDefault();\n if (isMobile && !mobileOpen) {\n const activeElement = document.activeElement;\n setActiveTrigger(\n activeElement instanceof HTMLElement &&\n activeElement !== document.body &&\n activeElement.isConnected\n ? activeElement\n : null\n );\n }\n toggle();\n };\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, [isMobile, keyboardShortcut, mobileOpen, setActiveTrigger, toggle]);\n\n const value = useMemo(\n () => ({\n state: open ? \"expanded\" : \"collapsed\",\n open,\n setOpen,\n mobileOpen,\n setMobileOpen,\n isMobile,\n triggerRef,\n setActiveTrigger,\n toggle,\n closeMobile,\n }),\n [closeMobile, isMobile, mobileOpen, open, setActiveTrigger, setMobileOpen, setOpen, toggle]\n );\n\n return {children};\n};\n\nSidebarProvider.displayName = \"SidebarProvider\";\n\nexport interface SidebarProps extends ComponentPropsWithoutRef<\"div\"> {\n variant?: SidebarVariant;\n collapsible?: SidebarCollapsible;\n width?: string;\n collapsedWidth?: string;\n mobileWidth?: string;\n /** Accessible name shared by the desktop aside and compact drawer. */\n ariaLabel?: string;\n /** Logical edge occupied by the sidebar. */\n side?: SidebarSide;\n /** @deprecated Use ariaLabel. */\n mobileLabel?: string;\n}\n\nconst Sidebar = forwardRef(\n (\n {\n variant = \"sidebar\",\n collapsible = \"offcanvas\",\n width = \"16rem\",\n collapsedWidth = \"3rem\",\n mobileWidth = \"16rem\",\n ariaLabel,\n side = \"start\",\n mobileLabel,\n className,\n children,\n style,\n dir: dirProp,\n ...props\n },\n forwardedRef\n ) => {\n const { state, isMobile, mobileOpen, closeMobile, triggerRef } = useSidebar();\n const reduceMotion = useReducedMotion() ?? false;\n const shape = useShape();\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n const parentSurface = useSurface();\n const surface = variant === \"floating\" ? resolveSurface(parentSurface, \"raised\") : parentSurface;\n const resolvedAriaLabel = ariaLabel ?? mobileLabel ?? \"Navigation\";\n const visualState: SidebarState = isMobile || collapsible === \"none\" ? \"expanded\" : state;\n const panelWidth = visualState === \"collapsed\" && collapsible === \"icon\"\n ? \"var(--sidebar-width-collapsed)\"\n : \"var(--sidebar-width)\";\n const offcanvas = visualState === \"collapsed\" && collapsible === \"offcanvas\";\n const physicalLeft = (side === \"start\") === (dir !== \"rtl\");\n const offcanvasX = physicalLeft ? \"-100%\" : \"100%\";\n const rootStyle = {\n ...style,\n \"--sidebar-width\": width,\n \"--sidebar-width-collapsed\": collapsedWidth,\n \"--sidebar-width-mobile\": mobileWidth,\n } as CSSProperties;\n\n const renderPanel = (mobile: boolean) => (\n \n {mobile ? children : {children}}\n \n );\n\n if (isMobile) {\n return (\n \n \n {renderPanel(true)}\n \n \n );\n }\n\n return (\n \n \n \n {renderPanel(false)}\n \n \n );\n }\n);\n\nSidebar.displayName = \"Sidebar\";\n\nexport interface SidebarTriggerProps extends Omit, \"children\"> {\n label?: string;\n}\n\nconst SidebarTrigger = forwardRef(\n ({ label = \"Toggle sidebar\", onClick, ...props }, forwardedRef) => {\n const { isMobile, mobileOpen, state, toggle, setActiveTrigger } = useSidebar();\n const MenuIcon = useIcon(\"menu\");\n const CollapseIcon = useIcon(\"chevrons-left\");\n const ExpandIcon = useIcon(\"chevrons-right\");\n const Icon = isMobile ? MenuIcon : state === \"collapsed\" ? ExpandIcon : CollapseIcon;\n return (\n {\n onClick?.(event);\n if (event.defaultPrevented) return;\n // Only an external closed → open trigger owns final focus. The\n // matching close button rendered inside the drawer must not replace\n // the owner with an element that is about to unmount.\n if (isMobile && !mobileOpen) setActiveTrigger(event.currentTarget);\n toggle();\n }}\n {...props}\n >\n \n \n );\n }\n);\n\nSidebarTrigger.displayName = \"SidebarTrigger\";\n\nexport interface SidebarFloatingTriggerProps extends Omit, \"children\"> {\n /** The Sidebar collapse mode that exposes this trigger. */\n collapsedBehavior?: SidebarCollapsible;\n /** Accessible label for the collapsed-navigation trigger. */\n label?: string;\n /** Renders the complete navigation panel; call close() after an in-place navigation action. */\n renderContent: (controls: { close: () => void }) => ReactNode;\n /** Classes for the anchored navigation popover. */\n contentClassName?: string;\n}\n\nconst SidebarFloatingTrigger = forwardRef(\n (\n {\n collapsedBehavior = \"offcanvas\",\n contentClassName,\n label = \"Expand sidebar\",\n renderContent,\n className,\n onClick,\n ...props\n },\n forwardedRef\n ) => {\n const { isMobile, state, toggle } = useSidebar();\n const [open, setOpen] = useState(false);\n const ExpandIcon = useIcon(\"chevrons-right\");\n const visible = !isMobile && state === \"collapsed\" && collapsedBehavior === \"offcanvas\";\n\n useEffect(() => {\n if (!visible) setOpen(false);\n }, [visible]);\n\n if (!visible) return null;\n\n return (\n \n {\n onClick?.(event);\n if (event.defaultPrevented) return;\n setOpen(false);\n toggle();\n }}\n {...props}\n >\n \n \n }\n />\n \n {renderContent({ close: () => setOpen(false) })}\n \n \n );\n }\n);\n\nSidebarFloatingTrigger.displayName = \"SidebarFloatingTrigger\";\n\nexport type SidebarRailProps = ComponentPropsWithoutRef<\"button\">;\n\nconst SidebarRail = forwardRef(({ className, onClick, ...props }, ref) => {\n const { toggle } = useSidebar();\n return (\n {\n onClick?.(event);\n if (!event.defaultPrevented) toggle();\n }}\n className={cn(\n \"absolute inset-y-0 -end-2 z-raised hidden w-4 cursor-pointer xl:block\",\n \"group-data-[side=end]/sidebar:end-auto group-data-[side=end]/sidebar:start-[-0.5rem]\",\n className\n )}\n {...props}\n />\n );\n});\n\nSidebarRail.displayName = \"SidebarRail\";\n\nexport type SidebarHeaderProps = ComponentPropsWithoutRef<\"div\">;\nexport type SidebarFooterProps = ComponentPropsWithoutRef<\"div\">;\nexport interface SidebarContentProps extends ComponentPropsWithoutRef<\"div\"> {\n viewportClassName?: string;\n contentClassName?: string;\n}\nexport interface SidebarGroupProps\n extends Omit, \"render\"> {\n /** Enables the label trigger and collapsible group content. */\n collapsible?: boolean;\n}\nexport type SidebarGroupLabelProps = ComponentPropsWithoutRef<\"div\">;\nexport interface SidebarGroupTriggerProps\n extends ComponentPropsWithoutRef {\n indicator?: ReactNode;\n}\nexport type SidebarGroupActionProps = Omit, \"size\" | \"variant\">;\nexport type SidebarGroupContentProps = ComponentPropsWithoutRef;\nexport type SidebarSeparatorProps = ComponentPropsWithoutRef<\"hr\">;\n\nconst SidebarHeader = forwardRef(({ className, ...props }, ref) => (\n
\n));\nSidebarHeader.displayName = \"SidebarHeader\";\n\nconst SidebarContent = forwardRef(({\n className,\n viewportClassName,\n contentClassName,\n children,\n ...props\n}, ref) => (\n \n \n {children}\n
\n \n));\nSidebarContent.displayName = \"SidebarContent\";\n\nconst SidebarFooter = forwardRef(({ className, ...props }, ref) => (\n
\n));\nSidebarFooter.displayName = \"SidebarFooter\";\n\nconst SidebarGroup = forwardRef(({\n collapsible = false,\n open,\n defaultOpen = true,\n disabled,\n className,\n ...props\n}, ref) => (\n }\n render={
}\n open={collapsible ? open : true}\n defaultOpen={collapsible && open === undefined ? defaultOpen : undefined}\n disabled={collapsible ? disabled : true}\n data-slot=\"sidebar-group\"\n data-collapsible={collapsible ? \"true\" : \"false\"}\n className={cn(\"group/sidebar-group relative min-w-0\", className)}\n {...props}\n />\n));\nSidebarGroup.displayName = \"SidebarGroup\";\n\nconst SidebarGroupLabel = forwardRef(({ className, ...props }, ref) => (\n
\n));\nSidebarGroupLabel.displayName = \"SidebarGroupLabel\";\n\nconst SidebarGroupTrigger = forwardRef(({\n className,\n children,\n indicator,\n type = \"button\",\n ...props\n}, ref) => {\n const Chevron = useIcon(\"chevron-right\");\n const shape = useShape();\n\n return (\n \n {children}\n {indicator ?? (\n \n )}\n \n );\n});\nSidebarGroupTrigger.displayName = \"SidebarGroupTrigger\";\n\nconst SidebarGroupAction = forwardRef(({ className, type = \"button\", ...props }, ref) => (\n \n));\nSidebarGroupAction.displayName = \"SidebarGroupAction\";\n\nconst SidebarGroupContent = forwardRef(({ className, children, ...props }, ref) => (\n \n
{children}
\n \n));\nSidebarGroupContent.displayName = \"SidebarGroupContent\";\n\nconst SidebarSeparator = forwardRef(({ className, ...props }, ref) => (\n
\n));\nSidebarSeparator.displayName = \"SidebarSeparator\";\n\nexport {\n SidebarProvider,\n Sidebar,\n SidebarTrigger,\n SidebarFloatingTrigger,\n SidebarRail,\n SidebarHeader,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarGroupTrigger,\n SidebarGroupAction,\n SidebarGroupContent,\n SidebarSeparator,\n};\n", "type": "registry:ui", "target": "components/ui/sidebar.tsx" } ], "type": "registry:ui" }