Sidebar
An app sidebar that collapses to icons or off canvas, stays pinned under your header, and becomes a swipeable sheet on phones.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/sidebar.jsonInstall manually
Install the dependencies:
npm install @base-ui/react @tabler/icons-react class-variance-authority cnIt builds on these, so copy them first:
Copy this into
components/ui/sidebar.tsx:sidebar.tsx"use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" import { useRender } from "@base-ui/react/use-render" import { IconCheck, IconChevronRight, IconLayoutSidebar, IconX, } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useComposedRef } from "@/hooks/use-composed-ref" import { matchesHotkey } from "@/lib/hotkey" import { Button } from "@/components/ui/button" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { NumberFlow } from "@/components/ui/number-flow" import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet" import { Skeleton } from "@/components/ui/skeleton" import { createTooltipHandle, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, type TooltipContentProps, } from "@/components/ui/tooltip" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const SIDEBAR_KEYBOARD_SHORTCUT = "mod+b" const mobileQuery = "(max-width: 767px)" function subscribeMobile(onChange: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const media = window.matchMedia(mobileQuery) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) } function getMobile() { return typeof window.matchMedia === "function" ? window.matchMedia(mobileQuery).matches : false } function useIsMobile() { return React.useSyncExternalStore(subscribeMobile, getMobile, () => false) } const shortcutOwners = new Map<Element, string>() function focusOwnsShortcut(wrapper: Element, shortcut: string) { const focused = document.activeElement let owner = focused instanceof Element ? focused.closest("[data-slot=sidebar-wrapper]") : null while (owner) { if (shortcutOwners.get(owner) === shortcut) { return owner === wrapper } owner = owner.parentElement?.closest("[data-slot=sidebar-wrapper]") ?? null } return true } function isEditable(target: EventTarget | null) { return target instanceof HTMLElement && target.isContentEditable } type SidebarState = "expanded" | "collapsed" type SidebarContextProps = { state: SidebarState open: boolean setOpen: (open: boolean | ((open: boolean) => boolean)) => void openMobile: boolean setOpenMobile: (open: boolean | ((open: boolean) => boolean)) => void isMobile: boolean toggleSidebar: () => void } const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarWrapperContext = React.createContext<{ sidebarId: string wrapperRef: React.RefObject<HTMLDivElement | null> returnFocusRef: React.RefObject<HTMLElement | null> } | null>(null) const SidebarTooltipContext = React.createContext<ReturnType< typeof createTooltipHandle<React.ReactNode> > | null>(null) function useSidebar() { const context = React.useContext(SidebarContext) if (!context) { throw new Error("useSidebar must be used within a SidebarProvider.") } return context } function useSidebarWrapper() { const context = React.useContext(SidebarWrapperContext) if (!context) { throw new Error("Sidebar must be used within a SidebarProvider.") } return context } type SidebarProviderProps = React.ComponentProps<"div"> & { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void keyboardShortcut?: string | null } function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange, keyboardShortcut = SIDEBAR_KEYBOARD_SHORTCUT, className, children, ref, ...props }: SidebarProviderProps) { const isMobile = useIsMobile() const sidebarId = React.useId() const [wrapperRef, setWrapperRef] = useComposedRef<HTMLDivElement>(ref) const [openMobile, setOpenMobileState] = React.useState(false) const [wasMobile, setWasMobile] = React.useState(isMobile) if (isMobile !== wasMobile) { setWasMobile(isMobile) if (!isMobile) { setOpenMobileState(false) } } const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const openRef = React.useRef(open) const controlledRef = React.useRef(openProp !== undefined) const onOpenChangeRef = React.useRef(onOpenChange) React.useLayoutEffect(() => { openRef.current = open controlledRef.current = openProp !== undefined onOpenChangeRef.current = onOpenChange }) const setOpen = React.useCallback( (value: boolean | ((open: boolean) => boolean)) => { const next = typeof value === "function" ? value(openRef.current) : value if (next === openRef.current) { return } openRef.current = next if (!controlledRef.current) { setUncontrolledOpen(next) } onOpenChangeRef.current?.(next) }, [] ) const returnFocusRef = React.useRef<HTMLElement | null>(null) const setOpenMobile = React.useCallback( (value: boolean | ((open: boolean) => boolean)) => { const focused = document.activeElement if (!focused?.closest("[data-mobile=true]")) { returnFocusRef.current = focused instanceof HTMLElement && focused !== document.body ? focused : null } setOpenMobileState(value) }, [] ) const toggleSidebar = React.useCallback(() => { if (isMobile) { setOpenMobile((value) => !value) } else { setOpen((value) => !value) } }, [isMobile, setOpen, setOpenMobile]) const toggleRef = React.useRef(toggleSidebar) React.useLayoutEffect(() => { toggleRef.current = toggleSidebar }) React.useEffect(() => { const wrapper = wrapperRef.current if (!keyboardShortcut || !wrapper) { return } shortcutOwners.set(wrapper, keyboardShortcut) const handleKeyDown = (event: KeyboardEvent) => { if ( event.defaultPrevented || event.repeat || isEditable(event.target) || !matchesHotkey(event, keyboardShortcut) || !focusOwnsShortcut(wrapper, keyboardShortcut) ) { return } event.preventDefault() toggleRef.current() } window.addEventListener("keydown", handleKeyDown) return () => { window.removeEventListener("keydown", handleKeyDown) shortcutOwners.delete(wrapper) } }, [keyboardShortcut]) const state: SidebarState = open ? "expanded" : "collapsed" const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] ) const wrapperValue = React.useMemo( () => ({ sidebarId, wrapperRef, returnFocusRef }), [sidebarId] ) return ( <SidebarContext.Provider value={contextValue}> <SidebarWrapperContext.Provider value={wrapperValue}> <div ref={setWrapperRef} data-slot="sidebar-wrapper" className={cn( "group/sidebar-wrapper flex min-h-svh w-full [--sidebar-top:0px] [--sidebar-width-icon:3rem] [--sidebar-width-mobile:18rem] [--sidebar-width:16rem] has-[>*>[data-slot=sidebar][data-variant=inset]]:bg-sidebar has-[>[data-slot=sidebar][data-variant=inset]]:bg-sidebar", className )} {...props} > {children} </div> </SidebarWrapperContext.Provider> </SidebarContext.Provider> ) } function SidebarTooltips({ side, children, }: { side: "left" | "right" children: React.ReactNode }) { const [handle] = React.useState(() => createTooltipHandle<React.ReactNode>()) return ( <TooltipProvider delay={0} closeDelay={100}> <SidebarTooltipContext.Provider value={handle}> {children} </SidebarTooltipContext.Provider> <Tooltip handle={handle}> {({ payload }) => ( <TooltipContent side={side === "left" ? "right" : "left"}> {payload as React.ReactNode} </TooltipContent> )} </Tooltip> </TooltipProvider> ) } function isLinkNavigation(event: React.MouseEvent) { if ( event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || !(event.target instanceof Element) ) { return false } const link = event.target.closest("a[href]") return ( link instanceof HTMLAnchorElement && link.getAttribute("aria-disabled") !== "true" && !link.hasAttribute("download") && (link.target === "" || link.target === "_self") ) } type SidebarProps = React.ComponentProps<"div"> & { side?: "left" | "right" variant?: "sidebar" | "floating" | "inset" | "plain" collapsible?: "offcanvas" | "icon" | "none" mobile?: "sheet" | "fullscreen" } function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", mobile = "sheet", className, children, dir, ...props }: SidebarProps) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { sidebarId, wrapperRef, returnFocusRef } = useSidebarWrapper() const containerRef = React.useRef<HTMLDivElement | null>(null) const hidden = collapsible === "offcanvas" && state === "collapsed" React.useLayoutEffect(() => { const container = containerRef.current const focused = document.activeElement if (!hidden || !container || !focused || !container.contains(focused)) { return } const trigger = Array.from( document.querySelectorAll<HTMLElement>("[data-slot=sidebar-trigger]") ).find((node) => node.getAttribute("aria-controls") === sidebarId) if (trigger) { trigger.focus({ preventScroll: true }) } else if (focused instanceof HTMLElement) { focused.blur() } }, [hidden, sidebarId]) const syncMobileWidth = React.useCallback( (popup: HTMLDivElement | null) => { const wrapper = wrapperRef.current if (!popup || !wrapper) { return } const width = getComputedStyle(wrapper) .getPropertyValue("--sidebar-width-mobile") .trim() popup.style.setProperty("--sidebar-width", width || "18rem") }, [wrapperRef] ) if (collapsible === "none") { return ( <SidebarTooltips side={side}> <div id={sidebarId} data-slot="sidebar" data-side={side} data-variant={variant} dir={dir} className={cn( "group flex h-full w-(--sidebar-width) min-w-0 flex-col text-sidebar-foreground", variant !== "plain" && "bg-sidebar", className )} {...props} > {children} </div> </SidebarTooltips> ) } if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={(open) => setOpenMobile(open)}> <SheetContent ref={syncMobileWidth} finalFocus={() => { const previous = returnFocusRef.current if (previous?.isConnected) { return previous } return ( wrapperRef.current?.querySelector<HTMLElement>( "[data-slot=sidebar-trigger]" ) ?? true ) }} dir={dir} side={side} showCloseButton={false} data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" data-mobile-layout={mobile} className="w-[calc(var(--sidebar-width)+var(--bleed))] max-w-[calc(100%-3rem+var(--bleed))] gap-0 bg-sidebar text-sidebar-foreground data-[mobile-layout=fullscreen]:w-[calc(100%+var(--bleed))] data-[mobile-layout=fullscreen]:max-w-none data-[mobile-layout=fullscreen]:rounded-none data-[mobile-layout=fullscreen]:shadow-none data-[mobile-layout=fullscreen]:ring-0" onClick={(event) => { if (isLinkNavigation(event)) { setOpenMobile(false) } }} > <SheetHeader className="sr-only"> <SheetTitle>Sidebar</SheetTitle> </SheetHeader> <SidebarTooltips side={side}> <div data-slot="sidebar-inner" data-sidebar="sidebar" className="flex h-full w-full min-w-0 flex-col pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]" > {children} </div> </SidebarTooltips> </SheetContent> </Sheet> ) } const detached = variant === "floating" || variant === "inset" return ( <div data-slot="sidebar" data-state={state} data-collapsible={state === "collapsed" ? collapsible : ""} data-variant={variant} data-side={side} className={cn( "group peer sticky top-(--sidebar-top) hidden h-[calc(100svh-var(--sidebar-top))] max-h-full w-(--sidebar-width) shrink-0 self-start text-sidebar-foreground transition-[width] duration-250 ease-drawer data-[collapsible=offcanvas]:w-0 motion-reduce:transition-none md:block", detached ? "data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+--spacing(4))]" : "data-[collapsible=icon]:w-(--sidebar-width-icon)" )} > <SidebarTooltips side={side}> <div ref={containerRef} id={sidebarId} data-slot="sidebar-container" data-side={side} dir={dir} className={cn( "absolute inset-y-0 z-10 flex w-(--sidebar-width) transition-[translate,width] duration-250 ease-drawer data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:-translate-x-full data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:translate-x-full motion-reduce:transition-none", detached ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+--spacing(4))]" : variant === "plain" ? "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) after:pointer-events-none after:absolute after:inset-y-0 after:w-(--hairline) after:bg-sidebar-border data-[side=left]:after:right-0 data-[side=right]:after:left-0", className )} {...props} > <div data-slot="sidebar-inner" data-sidebar="sidebar" className="flex size-full min-w-0 flex-col bg-sidebar transition-[visibility] duration-250 group-data-[collapsible=offcanvas]:invisible group-data-[variant=floating]:rounded-xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-(length:--hairline) group-data-[variant=floating]:ring-sidebar-border group-data-[variant=plain]:bg-transparent motion-reduce:transition-none" > {children} </div> </div> </SidebarTooltips> </div> ) } function SidebarTrigger({ className, onClick, children, ...props }: React.ComponentProps<typeof Button>) { const { toggleSidebar, open, openMobile, isMobile } = useSidebar() const { sidebarId } = useSidebarWrapper() return ( <Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon-sm" aria-expanded={isMobile ? openMobile : open} aria-controls={isMobile ? undefined : sidebarId} className={cn("aria-expanded:not-hover:bg-transparent", className)} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { toggleSidebar() } }} {...props} > {children ?? ( <> <IconLayoutSidebar className="rtl:-scale-x-100" /> <span className="sr-only">Toggle Sidebar</span> </> )} </Button> ) } function SidebarRail({ className, onClick, ...props }: React.ComponentProps<"button">) { const { toggleSidebar } = useSidebar() return ( <button type="button" data-sidebar="rail" data-slot="sidebar-rail" aria-label="Toggle Sidebar" tabIndex={-1} title="Toggle Sidebar" onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { toggleSidebar() } }} className={cn( "visible absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:transition-colors after:duration-150 hover:after:bg-sidebar-border focus-visible:outline-hidden sm:flex", "group-data-[side=left]:-right-4 group-data-[side=left]:cursor-w-resize group-data-[side=right]:left-0 group-data-[side=right]:cursor-e-resize", "group-data-[side=left]:group-data-[state=collapsed]:cursor-e-resize group-data-[side=right]:group-data-[state=collapsed]:cursor-w-resize", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:transition-colors group-data-[collapsible=offcanvas]:duration-150 group-data-[side=left]:group-data-[collapsible=offcanvas]:after:left-0 group-data-[side=right]:group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "group-data-[side=right]:group-data-[collapsible=offcanvas]:-left-4", className )} {...props} /> ) } function SidebarInset({ className, render, ...props }: useRender.ComponentProps<"main">) { return useRender({ defaultTagName: "main", render, props: mergeProps<"main">( { className: cn( "relative flex w-full min-w-0 flex-1 flex-col bg-background motion-reduce:transition-none md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:ring-(length:--hairline) md:peer-data-[variant=inset]:ring-sidebar-border md:peer-data-[variant=inset]:duration-250 md:peer-data-[variant=inset]:ease-drawer md:peer-data-[variant=inset]:peer-data-[collapsible=offcanvas]:ms-2 motion-safe:md:peer-data-[variant=inset]:transition-[margin]", className ), }, props, { "data-slot": "sidebar-inset" } as React.ComponentProps<"main"> ), }) } function SidebarInput({ className, size = "sm", ...props }: React.ComponentProps<typeof Input>) { return ( <Input data-slot="sidebar-input" data-sidebar="input" size={size} className={mergeClassName("w-full bg-background", className)} {...props} /> ) } function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-header" data-sidebar="header" className={cn("flex min-w-0 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 min-w-0 flex-col gap-2 p-2", className)} {...props} /> ) } function SidebarSeparator({ className, ...props }: SeparatorPrimitive.Props) { return ( <SeparatorPrimitive data-slot="sidebar-separator" data-sidebar="separator" className={mergeClassName( "mx-2 h-(--hairline) w-auto shrink-0 bg-sidebar-border", className )} {...props} /> ) } function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-content" data-sidebar="content" className={cn( "flex min-h-0 flex-1 [scrollbar-width:thin] [scrollbar-color:var(--color-sidebar-border)_transparent] flex-col gap-2 overflow-x-hidden overflow-y-auto overscroll-contain group-data-[collapsible=icon]:overflow-y-hidden group-data-[variant=plain]:no-scrollbar supports-[animation-timeline:scroll()]:scroll-fade-y", 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} /> ) } const iconCollapseFade = "transition-[opacity,visibility] delay-150 duration-150 ease-out-cubic group-data-[collapsible=icon]:invisible group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:delay-0 group-data-[collapsible=icon]:duration-100 motion-reduce:transition-none" function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", props: mergeProps<"div">( { className: cn( "flex h-8 min-w-0 shrink-0 items-center gap-2 overflow-hidden rounded-md px-2 text-start text-xs font-medium whitespace-nowrap text-sidebar-foreground/70 inset-ring-(length:--hairline) inset-ring-transparent transition-[margin,opacity,visibility,color,box-shadow] duration-250 ease-drawer outline-none [-webkit-tap-highlight-color:transparent] group-data-[collapsible=icon]:invisible group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-sidebar-ring focus-visible:outline-hidden motion-reduce:transition-none forced-colors:border [&>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">) { return useRender({ defaultTagName: "button", props: mergeProps<"button">( { type: render ? undefined : "button", className: cn( "absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground inset-ring-(length:--hairline) inset-ring-transparent outline-none [-webkit-tap-highlight-color:transparent] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-sidebar-ring focus-visible:outline-hidden forced-colors:border pointer-coarse:after:absolute pointer-coarse:after:-inset-2 [&>svg]:size-4 [&>svg]:shrink-0", iconCollapseFade, 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 min-w-0 text-sm", className)} {...props} /> ) } function SidebarMenu({ className, gap = "default", ...props }: React.ComponentProps<"ul"> & { gap?: "default" | "none" }) { return ( <ul data-slot="sidebar-menu" data-sidebar="menu" data-gap={gap} className={cn( "flex w-full min-w-0 flex-col gap-1 group-data-[variant=plain]:gap-0 data-[gap=none]: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 min-w-0", className)} {...props} /> ) } const sidebarMenuButtonVariants = cva( "peer/menu-button group/menu-button flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm inset-ring-(length:--hairline) inset-ring-transparent transition-[width,height,padding,background-color,color,box-shadow] duration-[250ms,250ms,250ms,100ms,100ms,100ms] ease-drawer outline-none [-webkit-tap-highlight-color:transparent] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-has-data-[sidebar=menu-badge]/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 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-sidebar-ring focus-visible:outline-hidden 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-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground motion-reduce:transition-none forced-colors:border data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground forced-colors:data-active:outline-2 forced-colors:data-active:-outline-offset-2 forced-colors:data-active:outline-solid [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span:last-child]:truncate", { variants: { variant: { default: "", outline: "bg-background inset-ring-sidebar-border hover:inset-ring-sidebar-accent dark:bg-input/30", }, size: { default: "h-8 text-sm pointer-coarse:h-10", sm: "h-7 text-xs pointer-coarse:h-9", lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", }, }, defaultVariants: { variant: "default", size: "default", }, } ) function isTooltipProps( tooltip: React.ReactNode | TooltipContentProps ): tooltip is TooltipContentProps { return ( typeof tooltip === "object" && tooltip !== null && !React.isValidElement(tooltip) && !(Symbol.iterator in tooltip) && !("then" in tooltip) ) } type SidebarMenuButtonProps = useRender.ComponentProps<"button"> & VariantProps<typeof sidebarMenuButtonVariants> & { isActive?: boolean tooltip?: React.ReactNode | TooltipContentProps } function SidebarMenuButton({ render, isActive = false, variant = "default", size = "default", tooltip, className, ...props }: SidebarMenuButtonProps) { const { isMobile, state } = useSidebar() const handle = React.useContext(SidebarTooltipContext) const hasTooltip = tooltip !== undefined && tooltip !== null && tooltip !== false const custom = hasTooltip && (isTooltipProps(tooltip) || handle === null) const tooltipDisabled = state !== "collapsed" || isMobile const element = useRender({ defaultTagName: "button", props: mergeProps<"button">( { type: render || hasTooltip ? undefined : "button", "aria-current": isActive ? "page" : undefined, className: cn(sidebarMenuButtonVariants({ variant, size }), className), }, props ), render: !hasTooltip ? ( render ) : custom ? ( <TooltipTrigger render={render} disabled={tooltipDisabled} /> ) : ( <TooltipTrigger render={render} handle={handle ?? undefined} payload={tooltip as React.ReactNode} disabled={tooltipDisabled} /> ), state: { slot: "sidebar-menu-button", sidebar: "menu-button", size, active: isActive, }, }) if (!custom) { return element } const content = isTooltipProps(tooltip) ? tooltip : { children: tooltip as React.ReactNode } return ( <Tooltip> {element} <TooltipContent side="right" {...content} /> </Tooltip> ) } function SidebarMenuAction({ className, render, showOnHover = false, replacesBadge = false, ...props }: useRender.ComponentProps<"button"> & { showOnHover?: boolean replacesBadge?: boolean }) { return useRender({ defaultTagName: "button", props: mergeProps<"button">( { type: render ? undefined : "button", className: cn( "absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground inset-ring-(length:--hairline) inset-ring-transparent outline-none [-webkit-tap-highlight-color:transparent] peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-3.5 peer-data-[size=sm]/menu-button:top-1 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-sidebar-ring focus-visible:outline-hidden forced-colors:border pointer-coarse:peer-data-[size=default]/menu-button:top-2.5 pointer-coarse:peer-data-[size=sm]/menu-button:top-2 pointer-coarse:after:absolute pointer-coarse:after:-inset-2 [&>svg]:size-4 [&>svg]:shrink-0", iconCollapseFade, showOnHover && "delay-0 peer-data-active/menu-button:text-sidebar-accent-foreground pointer-fine:opacity-0 pointer-fine:group-focus-within/menu-item:opacity-100 pointer-fine:group-hover/menu-item:opacity-100 pointer-fine:aria-expanded:opacity-100 pointer-fine:data-popup-open:opacity-100", className ), }, replacesBadge ? ({ "data-replaces-badge": "" } as React.ComponentProps<"button">) : {}, props ), render, state: { slot: "sidebar-menu-action", sidebar: "menu-action", }, }) } function SidebarMenuBadge({ className, count, max = 99, label, locale, dot = false, tone = "default", children, ...props }: React.ComponentProps<"div"> & { count?: number max?: number label?: string locale?: Intl.LocalesArgument dot?: boolean tone?: "default" | "success" | "warning" | "destructive" }) { const counted = count !== undefined if (counted && count <= 0 && children === undefined) { return null } const capped = counted && count > max return ( <div data-slot="sidebar-menu-badge" data-sidebar="menu-badge" data-count={counted ? count : undefined} data-dot={dot ? "" : undefined} data-tone={tone} className={cn( "pointer-events-none absolute end-1 top-1.5 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 peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-3.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground group-has-data-[sidebar=menu-collapsible-trigger]/menu-item:end-7 pointer-coarse:peer-data-[size=default]/menu-button:top-2.5 pointer-coarse:peer-data-[size=sm]/menu-button:top-2", "data-[tone=destructive]:text-destructive data-[tone=success]:text-success data-[tone=warning]:text-warning", "transition-opacity duration-100 pointer-fine:group-focus-within/menu-item:group-has-data-replaces-badge/menu-item:opacity-0 pointer-fine:group-hover/menu-item:group-has-data-replaces-badge/menu-item:opacity-0 motion-reduce:transition-none", dot ? "group-data-[collapsible=icon]:end-1.5 group-data-[collapsible=icon]:top-1.5 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:p-0" : counted ? "group-data-[collapsible=icon]:end-1.5 group-data-[collapsible=icon]:top-1.5 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:overflow-hidden group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:bg-sidebar-foreground group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:text-transparent forced-colors:group-data-[collapsible=icon]:bg-canvas-text" : iconCollapseFade, className )} {...props} > {dot ? ( <> <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-current forced-colors:bg-canvas-text" /> {label ? <span className="sr-only">{label}</span> : null} </> ) : counted ? ( <> <span aria-hidden className="group-data-[collapsible=icon]:hidden"> {capped ? ( `${new Intl.NumberFormat(locale).format(max)}+` ) : ( <NumberFlow value={count} locales={locale} /> )} </span> {label ? ( <span className="sr-only"> {new Intl.NumberFormat(locale).format(count)} {label} </span> ) : null} </> ) : ( children )} </div> ) } const skeletonWidths = [ "max-w-[52%]", "max-w-[60%]", "max-w-[68%]", "max-w-[76%]", "max-w-[84%]", "max-w-[90%]", ] function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<"div"> & { showIcon?: boolean }) { const id = React.useId() let hash = 0 for (const char of id) { hash = (hash * 31 + char.charCodeAt(0)) >>> 0 } return ( <div data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" aria-hidden="true" className={cn( "flex h-8 items-center gap-2 rounded-md px-2 pointer-coarse:h-10", className )} {...props} > {showIcon && ( <Skeleton className="size-4 shrink-0 rounded-md" data-sidebar="menu-skeleton-icon" /> )} <Skeleton className={cn( "h-4 flex-1 group-data-[collapsible=icon]:hidden", skeletonWidths[hash % skeletonWidths.length] )} data-sidebar="menu-skeleton-text" /> </div> ) } function SidebarMenuSub({ className, gap = "default", ...props }: React.ComponentProps<"ul"> & { gap?: "default" | "none" }) { return ( <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" data-gap={gap} className={cn( "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 overflow-clip border-s border-sidebar-border px-2.5 py-0.5 transition-[height,padding,opacity,visibility] duration-250 ease-drawer [interpolate-size:allow-keywords] [overflow-clip-margin:3px] group-data-[collapsible=icon]:invisible group-data-[collapsible=icon]:h-0 group-data-[collapsible=icon]:py-0 group-data-[collapsible=icon]:opacity-0 data-[gap=none]:gap-0 motion-reduce:transition-none rtl:-translate-x-px", 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 min-w-0", className)} {...props} /> ) } function SidebarMenuSubButton({ render, size = "md", isActive = false, className, ...props }: useRender.ComponentProps<"a"> & { size?: "sm" | "md" isActive?: boolean }) { return useRender({ defaultTagName: "a", props: mergeProps<"a">( { "aria-current": isActive ? "page" : undefined, className: cn( "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-start text-sidebar-foreground inset-ring-(length:--hairline) inset-ring-transparent transition-[background-color,color,box-shadow] duration-100 outline-none [-webkit-tap-highlight-color:transparent] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-sidebar-ring focus-visible:outline-hidden 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-xs motion-reduce:transition-none rtl:translate-x-px forced-colors:border pointer-coarse:h-9 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground forced-colors:data-active:outline-2 forced-colors:data-active:-outline-offset-2 forced-colors:data-active:outline-solid [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", className ), }, props ), render, state: { slot: "sidebar-menu-sub-button", sidebar: "menu-sub-button", size, active: isActive, }, }) } function SidebarMenuCollapsible({ render, ...props }: React.ComponentProps<typeof Collapsible>) { return ( <Collapsible data-slot="sidebar-menu-collapsible" render={render ?? <SidebarMenuItem />} {...props} /> ) } function SidebarMenuCollapsibleTrigger({ className, children, ...props }: React.ComponentProps<typeof CollapsibleTrigger>) { return ( <CollapsibleTrigger data-sidebar="menu-collapsible-trigger" render={<SidebarMenuAction />} className={mergeClassName( "group/menu-collapsible-trigger", className )} {...props} > {children ?? ( <IconChevronRight className="transition-transform duration-150 ease-out-quint group-data-panel-open/menu-collapsible-trigger:rotate-90 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-data-panel-open/menu-collapsible-trigger:-rotate-90" /> )} </CollapsibleTrigger> ) } function SidebarMenuCollapsibleContent({ className, ...props }: React.ComponentProps<typeof CollapsibleContent>) { return ( <CollapsibleContent data-sidebar="menu-collapsible-content" className={cn("group-data-[collapsible=icon]:hidden", className)} {...props} /> ) } function SidebarMenuFlyout({ label, content, children, side, }: { label: React.ReactNode content: React.ReactNode children: React.ReactElement side?: "left" | "right" }) { const { state, isMobile } = useSidebar() if (state !== "collapsed" || isMobile) { return children } return ( <DropdownMenu> <DropdownMenuTrigger openOnHover delay={150} render={children} /> <DropdownMenuContent data-slot="sidebar-menu-flyout" side={side ?? "right"} align="start" sideOffset={8} className="min-w-48" > <DropdownMenuGroup> <DropdownMenuLabel>{label}</DropdownMenuLabel> {content} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ) } function SidebarMenuFlyoutItem({ isActive = false, children, ...props }: React.ComponentProps<typeof DropdownMenuItem> & { isActive?: boolean }) { return ( <DropdownMenuItem data-sidebar="menu-flyout-item" data-active={isActive ? "" : undefined} aria-current={isActive ? "page" : undefined} {...props} > <span className="min-w-0 flex-1 truncate">{children}</span> {isActive ? <IconCheck aria-hidden /> : null} </DropdownMenuItem> ) } function SidebarCard({ className, children, onDismiss, dismissLabel = "Dismiss", ...props }: React.ComponentProps<"section"> & { onDismiss?: () => void dismissLabel?: string }) { const [closing, setClosing] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null) React.useEffect( () => () => { if (timer.current) { clearTimeout(timer.current) } }, [] ) const dismiss = () => { if (closing) { return } setClosing(true) const reduce = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches timer.current = setTimeout(() => onDismiss?.(), reduce ? 0 : 200) } return ( <div data-slot="sidebar-card-frame" data-closing={closing ? "" : undefined} className="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] duration-200 ease-out-quint group-data-[collapsible=icon]:hidden data-closing:grid-rows-[0fr] data-closing:opacity-0 motion-reduce:transition-none [@media(max-height:40rem)]:hidden" > <div className="min-h-0 overflow-hidden"> <section data-slot="sidebar-card" data-sidebar="card" className={cn( "relative flex flex-col gap-2 rounded-lg bg-background p-1.5 text-sm ring-(length:--hairline) ring-sidebar-border forced-colors:border motion-safe:animate-in motion-safe:fade-in-0 motion-safe:animation-duration-200", className )} {...props} > {children} {onDismiss ? ( <Button data-slot="sidebar-card-dismiss" variant="ghost" size="icon-xs" aria-label={dismissLabel} className="absolute end-1.5 top-1.5" onClick={dismiss} > <IconX /> </Button> ) : null} </section> </div> </div> ) } export { Sidebar, SidebarCard, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuCollapsible, SidebarMenuCollapsibleContent, SidebarMenuCollapsibleTrigger, SidebarMenuFlyout, SidebarMenuFlyoutItem, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, sidebarMenuButtonVariants, useSidebar, } export type { SidebarMenuButtonProps, SidebarProps, SidebarProviderProps, SidebarState, }
Examples
App shell
Collapsible
Controlled
Under a header
Right side
Right to left
Loading
Variants
Feedback
Questions, bugs or ideas for Sidebar? Post them here, or reply to someone else. Sign in with GitHub to comment.