An app sidebar that collapses to icons or off canvas, stays pinned under your header, and becomes a swipeable sheet on phones.
import {
IconBook,
IconChartBar,
IconChevronRight,
IconDots,
IconFolder,
IconHome,
IconInbox,
IconLayoutDashboard,
IconLogout,
IconSelector,
IconSettings,
IconShare,
IconTrash,
} from "@tabler/icons-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"
const projects = [
{ name: "Design engineering", icon: IconFolder },
{ name: "Sales & marketing", icon: IconChartBar },
{ name: "Travel", icon: IconBook },
]
export function SidebarDemo() {
return (
<SidebarProvider className="h-128 min-h-0">
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" />}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground">
A
</div>
<div className="grid min-w-0 flex-1 leading-tight">
<span className="truncate font-medium">Acme Inc</span>
<span className="truncate text-xs text-muted-foreground">
Enterprise
</span>
</div>
<IconSelector className="ms-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem>Acme Inc</DropdownMenuItem>
<DropdownMenuItem>Acme Corp.</DropdownMenuItem>
<DropdownMenuItem>Evil Corp.</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
render={<a href="#" />}
isActive
tooltip="Home"
>
<IconHome />
<span>Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="#" />} tooltip="Inbox">
<IconInbox />
<span>Inbox</span>
</SidebarMenuButton>
<SidebarMenuBadge>24</SidebarMenuBadge>
</SidebarMenuItem>
<Collapsible
defaultOpen
render={<SidebarMenuItem />}
className="group/collapsible"
>
<CollapsibleTrigger
render={<SidebarMenuButton tooltip="Dashboards" />}
>
<IconLayoutDashboard />
<span>Dashboards</span>
<IconChevronRight className="ms-auto duration-200 group-data-open/collapsible:rotate-90 motion-safe:transition-transform rtl:-scale-x-100" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton href="#">
<span>Overview</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton href="#">
<span>Revenue</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="#" />} tooltip="Settings">
<IconSettings />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{projects.map((project) => (
<SidebarMenuItem key={project.name}>
<SidebarMenuButton
render={<a href="#" />}
tooltip={project.name}
>
<project.icon />
<span>{project.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuAction showOnHover />}
>
<IconDots />
<span className="sr-only">More</span>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
<DropdownMenuItem>
<IconShare />
Share
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" />}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
OM
</div>
<div className="grid min-w-0 flex-1 leading-tight">
<span className="truncate font-medium">Olivia Martin</span>
<span className="truncate text-xs text-muted-foreground">
[email protected]
</span>
</div>
<IconSelector className="ms-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuItem>
<IconSettings />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ms-1" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Platform</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Home</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted" />
<div className="aspect-video rounded-xl bg-muted" />
<div className="aspect-video rounded-xl bg-muted" />
</div>
<div className="min-h-32 flex-1 rounded-xl bg-muted" />
</div>
</SidebarInset>
</SidebarProvider>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/sidebar.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/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 { IconLayoutSidebar } 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 { Input } from "@/components/ui/input" 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 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" } function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", 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" className="w-[calc(var(--sidebar-width)+var(--bleed))] max-w-[calc(100%-3rem+var(--bleed))] gap-0 bg-sidebar text-sidebar-foreground" 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-[state=collapsed]: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, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="sidebar-menu" data-sidebar="menu" className={cn( "flex w-full min-w-0 flex-col gap-1 group-data-[variant=plain]: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, ...props }: useRender.ComponentProps<"button"> & { showOnHover?: 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 ), }, props ), render, state: { slot: "sidebar-menu-action", sidebar: "menu-action", }, }) } function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sidebar-menu-badge" data-sidebar="menu-badge" className={cn( "pointer-events-none absolute end-1 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 pointer-coarse:peer-data-[size=default]/menu-button:top-2.5 pointer-coarse:peer-data-[size=sm]/menu-button:top-2", iconCollapseFade, className )} {...props} /> ) } 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, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" className={cn( "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 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 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, }, }) } 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, } export type { SidebarMenuButtonProps, SidebarProps, SidebarProviderProps, SidebarState, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }components/ui/input.tsx "use client" import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useAutosize } from "@/hooks/use-autosize" import { useComposedRef } from "@/hooks/use-composed-ref" import { useInvalidShake } from "@/hooks/use-invalid-shake" import { useMergedRef } from "@/hooks/use-merged-ref" import { prefersReducedMotion } from "@/lib/motion" import { NumberFlow } from "@/components/ui/number-flow" 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 inputVariants = cva( "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70", { variants: { size: { sm: "h-8 px-2.5 file:pe-2.5", default: "h-9 px-3", lg: "h-10 px-3.5", }, }, defaultVariants: { size: "default", }, } ) type InputControl = HTMLInputElement | HTMLTextAreaElement type InputProps = Omit<InputPrimitive.Props, "size"> & VariantProps<typeof inputVariants> & { htmlSize?: number shake?: boolean } function Input({ className, size = "default", htmlSize, shake = true, ref, ...props }: InputProps) { const resolvedSize = size ?? "default" const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref) useInvalidShake(inputRef, shake) return ( <InputPrimitive ref={setRef} data-slot="input" data-size={resolvedSize} size={htmlSize} className={mergeClassName( inputVariants({ size: resolvedSize }), className )} {...props} /> ) } function defaultAnnouncement(remaining: number) { return remaining === 0 ? "Character limit reached" : `${remaining} ${remaining === 1 ? "character" : "characters"} left` } type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & { length: number maxLength?: number | null controlRef?: React.RefObject<InputControl | null> threshold?: number announcement?: (remaining: number) => string } function InputCount({ className, length, maxLength = null, controlRef, threshold, announcement = defaultAnnouncement, ref, ...props }: InputCountProps) { const max = maxLength !== null && maxLength > 0 ? maxLength : null const remaining = max === null ? null : Math.max(0, max - length) const near = max === null ? Infinity : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20))) const state = remaining === null ? undefined : remaining === 0 ? "limit" : remaining <= near ? "near" : undefined const [message, setMessage] = React.useState("") const [lastState, setLastState] = React.useState(state) if (state !== lastState) { setLastState(state) setMessage(state && remaining !== null ? announcement(remaining) : "") } const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref) React.useEffect(() => { const element = controlRef?.current const count = countRef.current if (!element || !count || max === null) { return } let timer: ReturnType<typeof setTimeout> | undefined const onBeforeInput = (event: Event) => { const input = event as InputEvent if ( !input.inputType?.startsWith("insert") || element.value.length < max || element.selectionStart !== element.selectionEnd || prefersReducedMotion() ) { return } count.removeAttribute("data-bump") void count.offsetWidth count.setAttribute("data-bump", "") clearTimeout(timer) timer = setTimeout(() => count.removeAttribute("data-bump"), 400) } element.addEventListener("beforeinput", onBeforeInput) return () => { element.removeEventListener("beforeinput", onBeforeInput) clearTimeout(timer) } }, [controlRef, max]) return ( <span ref={setCountRef} data-slot="input-count" data-state={state} className={cn( "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground", className )} {...props} > <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline"> <NumberFlow value={length} /> {max !== null && <span>/{max}</span>} </span> <span role="status" className="sr-only"> {message} </span> </span> ) } export { Input, InputCount, useAutosize, inputVariants, useComposedRef, useInvalidShake, useMergedRef, } export type { InputControl, InputCountProps, InputProps }components/ui/sheet.tsx "use client" import * as React from "react" import { Drawer as SheetPrimitive } from "@base-ui/react/drawer" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" 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 DialogLayerContext = React.createContext(0) function DialogLayerProvider({ children }: { children: React.ReactNode }) { const depth = React.useContext(DialogLayerContext) return ( <DialogLayerContext.Provider value={depth + 1}> {children} </DialogLayerContext.Provider> ) } function useDialogLayer() { return React.useContext(DialogLayerContext) } type SwipeDirection = "up" | "down" | "left" | "right" const SheetRootContext = React.createContext< ((direction: SwipeDirection) => void) | null >(null) function Sheet<Payload>({ swipeDirection, ...props }: SheetPrimitive.Root.Props<Payload>) { const [contentDirection, setContentDirection] = React.useState<SwipeDirection>("right") return ( <DialogLayerProvider> <SheetRootContext.Provider value={setContentDirection}> <SheetPrimitive.Root data-slot="sheet" swipeDirection={swipeDirection ?? contentDirection} {...props} /> </SheetRootContext.Provider> </DialogLayerProvider> ) } function SheetTrigger<Payload>(props: SheetPrimitive.Trigger.Props<Payload>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> } function SheetPortal(props: SheetPrimitive.Portal.Props) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetClose(props: SheetPrimitive.Close.Props) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> } const sheetOverlayVariants = cva( "fixed inset-0 z-50 min-h-dvh opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:duration-200 sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:ease-out-quint sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:data-ending-style:duration-150 [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { nested: { false: "bg-black/40 dark:bg-black/60", true: "bg-black/20 dark:bg-black/30", }, }, defaultVariants: { nested: false, }, } ) function SheetOverlay({ className, forceRender, ...props }: SheetPrimitive.Backdrop.Props) { const nested = useDialogLayer() > 1 return ( <SheetPrimitive.Backdrop data-slot="sheet-overlay" data-nested={nested ? "" : undefined} forceRender={forceRender ?? nested} className={mergeClassName(sheetOverlayVariants({ nested }), className)} {...props} /> ) } const sheetContentVariants = cva( "fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-none bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,translate,scale,opacity,top] duration-300 ease-drawer outline-none [--bleed:3rem] focus-visible:outline-hidden data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-nested-dialog-open:scale-[calc(1-0.02*var(--nested-dialogs))] data-nested-drawer-open:scale-[calc(1-0.02*var(--nested-drawers))] data-swiping:cursor-grabbing data-swiping:select-none motion-reduce:transition-opacity motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 rtl:[--sheet-dir:-1] forced-colors:border [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { side: { top: "inset-x-0 top-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-b-2xl pt-(--bleed) [--sheet-close-top:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-y-[calc(-100%+var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(-100%+var(--bleed))]", right: "inset-y-0 end-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-s-2xl pe-(--bleed) [--sheet-close-end:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] sm:max-w-[calc(24rem+var(--bleed))]", bottom: "inset-x-0 bottom-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl pb-[calc(var(--bleed)+env(safe-area-inset-bottom))] [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-y-[calc(100%-var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(100%-var(--bleed))]", left: "inset-y-0 start-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-e-2xl ps-(--bleed) [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] sm:max-w-[calc(24rem+var(--bleed))]", }, }, defaultVariants: { side: "right", }, } ) const sheetHandleVariants = cva( "group/sheet-handle absolute z-10 flex cursor-grab touch-none items-center justify-center active:cursor-grabbing", { variants: { side: { top: "inset-x-0 bottom-0 h-6", right: "inset-y-0 start-0 w-6", bottom: "inset-x-0 top-0 h-6", left: "inset-y-0 end-0 w-6", }, }, } ) const sheetHandleBarVariants = cva( "rounded-full bg-muted-foreground/30 transition-colors duration-150 group-hover/sheet-handle:bg-muted-foreground/50 group-active/sheet-handle:bg-muted-foreground/60", { variants: { side: { top: "h-1 w-10", right: "h-10 w-1", bottom: "h-1 w-10", left: "h-10 w-1", }, }, } ) type SheetSide = NonNullable<VariantProps<typeof sheetContentVariants>["side"]> const SheetContentContext = React.createContext<{ showCloseButton: boolean }>({ showCloseButton: false }) function resolveSwipeDirection(side: SheetSide, rtl: boolean): SwipeDirection { if (side === "top") { return "up" } if (side === "bottom") { return "down" } return (side === "right") !== rtl ? "right" : "left" } function SheetContent({ className, children, side = "right", showCloseButton = true, dir, ...props }: SheetPrimitive.Popup.Props & VariantProps<typeof sheetContentVariants> & { showCloseButton?: boolean }) { const resolvedSide = side ?? "right" const setSwipeDirection = React.useContext(SheetRootContext) const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton]) React.useEffect(() => { const rtl = (dir ?? getComputedStyle(document.documentElement).direction) === "rtl" setSwipeDirection?.(resolveSwipeDirection(resolvedSide, rtl)) }, [dir, resolvedSide, setSwipeDirection]) return ( <SheetContentContext.Provider value={context}> <SheetPortal> <SheetOverlay /> <SheetPrimitive.Viewport data-slot="sheet-viewport" dir={dir} className="fixed inset-0 z-50" > <SheetPrimitive.Popup data-slot="sheet-content" data-side={resolvedSide} dir={dir} className={mergeClassName( sheetContentVariants({ side: resolvedSide }), className )} {...props} > <div data-slot="sheet-handle" aria-hidden="true" className={sheetHandleVariants({ side: resolvedSide })} > <div className={sheetHandleBarVariants({ side: resolvedSide })} /> </div> <SheetPrimitive.Content data-slot="sheet-inner" className="flex min-h-0 flex-1 flex-col gap-4" > {children} </SheetPrimitive.Content> {showCloseButton ? ( <SheetPrimitive.Close data-slot="sheet-close-button" aria-label="Close" render={<Button variant="ghost" size="icon-sm" />} className="absolute end-(--sheet-close-end,0.75rem) top-(--sheet-close-top,0.75rem) z-20 rounded-(--sheet-close-radius,var(--radius-md)) text-muted-foreground hover:text-foreground" > <IconX /> </SheetPrimitive.Close> ) : null} </SheetPrimitive.Popup> </SheetPrimitive.Viewport> </SheetPortal> </SheetContentContext.Provider> ) } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { const { showCloseButton } = React.useContext(SheetContentContext) return ( <div data-slot="sheet-header" className={cn( "flex flex-col gap-1.5 px-6 pt-6", showCloseButton && "pe-12", className )} {...props} /> ) } function SheetBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-body" className={cn( "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 wrap-anywhere", className )} {...props} /> ) } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn( "mt-auto flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10", className )} {...props} /> ) } function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere", className )} {...props} /> ) } function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={mergeClassName( "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } const createSheetHandle = SheetPrimitive.createHandle export { Sheet, SheetTrigger, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, SheetClose, sheetContentVariants, sheetOverlayVariants, createSheetHandle, DialogLayerProvider, useDialogLayer, }components/ui/skeleton.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" const skeletonVariants = cva( "relative overflow-hidden rounded-md bg-muted motion-safe:animate-in motion-safe:animation-duration-300 motion-safe:fade-in-0 motion-safe:fill-mode-backwards motion-safe:[animation-delay:150ms] rtl:[--skeleton-dir:-1] [&:where(span)]:inline-block [&:where(span)]:align-bottom", { variants: { animation: { shimmer: "after:pointer-events-none after:absolute after:inset-0 after:[translate:-100%_0] after:bg-linear-to-r after:from-transparent after:via-foreground/8 after:to-transparent motion-safe:after:animate-shimmer dark:after:via-foreground/10", pulse: "after:pointer-events-none after:absolute after:inset-0 after:bg-background after:opacity-0 motion-safe:after:animate-skeleton-pulse", none: "", }, }, defaultVariants: { animation: "shimmer", }, } ) type SkeletonAnimation = NonNullable< VariantProps<typeof skeletonVariants>["animation"] > type SkeletonProps = useRender.ComponentProps<"div"> & { animation?: SkeletonAnimation loading?: boolean } function useReveal(loading: boolean | undefined) { const [previous, setPrevious] = React.useState(loading) const [revealed, setRevealed] = React.useState(false) if (loading !== previous) { setPrevious(loading) setRevealed(previous === true && loading === false) } return revealed } function Skeleton({ className, animation = "shimmer", loading, render, children, ...props }: SkeletonProps) { const revealed = useReveal(loading) const wraps = loading !== undefined const showsSkeleton = !wraps || loading return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { ...({ "data-slot": "skeleton" } as Record<string, string>), className: cn( wraps && "w-fit max-w-full", showsSkeleton && skeletonVariants({ animation }), wraps && !loading && revealed && "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0", className ), "aria-hidden": wraps ? undefined : true, "aria-busy": loading || undefined, children: wraps ? ( <span data-slot="skeleton-content" className={cn("contents", loading && "invisible")} inert={loading || undefined} aria-hidden={loading || undefined} > {children} </span> ) : ( children ), }, props, { "data-animation": showsSkeleton ? animation : undefined, "data-loading": wraps ? String(Boolean(loading)) : undefined, } as Record<string, string | undefined> ), }) } type SkeletonTextProps = React.ComponentProps<"div"> & { lines?: number animation?: SkeletonAnimation } function SkeletonText({ className, lines = 3, animation, ...props }: SkeletonTextProps) { const count = Math.min( 50, Math.max(1, Math.floor(Number.isFinite(lines) ? lines : 1)) ) return ( <div data-slot="skeleton-text" aria-hidden="true" className={cn("flex w-full flex-col", className)} {...props} > {Array.from({ length: count }, (_, index) => ( <div key={index} className="flex h-[1lh] items-center"> <Skeleton animation={animation} className={cn( "h-[0.8em] rounded-sm", count > 1 && index === count - 1 ? "w-3/5" : "w-full" )} /> </div> ))} </div> ) } export { Skeleton, SkeletonText, skeletonVariants } export type { SkeletonProps, SkeletonTextProps, SkeletonAnimation }components/ui/tooltip.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "cn" 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) } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } const TooltipProviderContext = React.createContext(false) const TooltipGroupContext = React.createContext<TooltipPrimitive.Handle<React.ReactNode> | null>(null) const TooltipTriggerContext = React.createContext<React.RefObject<Element | null> | null>(null) function TooltipProvider({ delay = 300, closeDelay = 0, timeout = 400, ...props }: TooltipPrimitive.Provider.Props) { return ( <TooltipProviderContext.Provider value={true}> <TooltipPrimitive.Provider delay={delay} closeDelay={closeDelay} timeout={timeout} {...props} /> </TooltipProviderContext.Provider> ) } function Tooltip<Payload>({ onOpenChange, children, ...props }: TooltipPrimitive.Root.Props<Payload>) { const hasProvider = React.useContext(TooltipProviderContext) const inherited = useDirection() const triggerRef = React.useRef<Element | null>(null) const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const direction = triggerDirection === "rtl" ? "rtl" : inherited const root = ( <TooltipTriggerContext.Provider value={triggerRef}> <DirectionProvider direction={direction}> <TooltipPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (open && !eventDetails.isCanceled && eventDetails.trigger) { triggerRef.current = eventDetails.trigger setTriggerDirection(readDirection(eventDetails.trigger)) } }} {...props} > {children} </TooltipPrimitive.Root> </DirectionProvider> </TooltipTriggerContext.Provider> ) return hasProvider ? root : <TooltipProvider>{root}</TooltipProvider> } type TooltipTriggerProps<Payload> = Omit< TooltipPrimitive.Trigger.Props<Payload>, "content" > & { content?: React.ReactNode } function TooltipTrigger<Payload>({ ref, render, type, delay, handle, payload, content, ...props }: TooltipTriggerProps<Payload>) { const hasProvider = React.useContext(TooltipProviderContext) const triggerRef = React.useContext(TooltipTriggerContext) const groupHandle = React.useContext(TooltipGroupContext) const grouped = handle === undefined && groupHandle !== null const setRef = React.useCallback( (node: HTMLButtonElement | null) => { if (triggerRef && node) { triggerRef.current ??= node } if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } }, [ref, triggerRef] ) return ( <TooltipPrimitive.Trigger ref={setRef} data-slot="tooltip-trigger" render={render} delay={delay ?? (hasProvider ? undefined : 300)} type={type ?? (render === undefined ? "button" : undefined)} handle={ grouped ? (groupHandle as unknown as TooltipPrimitive.Handle<Payload>) : handle } payload={grouped ? (content as Payload) : payload} {...props} /> ) } function TooltipPortal(props: TooltipPrimitive.Portal.Props) { return <TooltipPrimitive.Portal data-slot="tooltip-portal" {...props} /> } type TooltipContentProps = TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "arrowPadding" | "collisionPadding" | "collisionAvoidance" | "collisionBoundary" | "sticky" | "positionMethod" | "disableAnchorTracking" > & { portalProps?: Omit<TooltipPrimitive.Portal.Props, "children"> arrow?: boolean } function TooltipContent({ className, children, side = "top", align = "center", arrow = false, sideOffset = arrow ? 8 : 6, alignOffset = 0, anchor, arrowPadding = 8, collisionPadding = 8, collisionAvoidance, collisionBoundary, sticky, positionMethod, disableAnchorTracking, portalProps, dir, ...props }: TooltipContentProps) { const direction = useDirection() const triggerRef = React.useContext(TooltipTriggerContext) const setPositioner = React.useCallback( (positioner: HTMLDivElement | null) => { if (!positioner || dir !== undefined || direction === "rtl") { return } if (readDirection(triggerRef?.current) === "rtl") { positioner.setAttribute("dir", "rtl") } }, [dir, direction, triggerRef] ) return ( <TooltipPrimitive.Portal {...portalProps}> <TooltipPrimitive.Positioner ref={setPositioner} data-slot="tooltip-positioner" dir={dir ?? (direction === "rtl" ? "rtl" : undefined)} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} arrowPadding={arrowPadding} collisionPadding={collisionPadding} collisionAvoidance={collisionAvoidance} collisionBoundary={collisionBoundary} sticky={sticky} positionMethod={positionMethod} disableAnchorTracking={disableAnchorTracking} className="isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) outline-none focus-visible:outline-hidden has-data-transitioning:duration-250 has-data-transitioning:ease-out-quint data-instant:transition-none motion-safe:has-data-transitioning:transition-[top,left,right,bottom] motion-reduce:transition-none" > <TooltipPrimitive.Popup data-slot="tooltip-content" className={mergeClassName( "relative h-(--popup-height,auto) w-(--popup-width,auto) max-w-[min(var(--container-2xs),var(--available-width))] origin-(--transform-origin) rounded-md bg-foreground text-xs/4 text-pretty wrap-anywhere text-background shadow-md/10 transition-[opacity,scale,width,height] duration-[150ms,150ms,250ms,250ms] ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none data-starting-style:opacity-0 motion-safe:data-starting-style:scale-96 motion-reduce:transition-opacity [&_[data-slot=kbd-separator]]:text-background/70 [&_kbd[data-slot=kbd][data-variant]]:rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--radius-md)-0.25rem))] [&_kbd[data-slot=kbd][data-variant]]:bg-background/15 [&_kbd[data-slot=kbd][data-variant]]:text-background [&_kbd[data-slot=kbd][data-variant]]:shadow-none [&_kbd[data-slot=kbd][data-variant]]:inset-ring-background/20 [&_kbd[data-slot=kbd][data-variant]]:[direction:ltr]", className )} {...props} > <TooltipPrimitive.Viewport ref={freezeOutgoingWidth} data-slot="tooltip-viewport" className={viewport} > {children} </TooltipPrimitive.Viewport> {arrow && <TooltipArrow />} </TooltipPrimitive.Popup> </TooltipPrimitive.Positioner> </TooltipPrimitive.Portal> ) } const viewport = "relative size-full overflow-clip rounded-[inherit] [&>*]:flex [&>*]:items-center [&>*]:justify-between [&>*]:gap-2 [&>*]:px-2 [&>*]:py-1 [&>*:has(>[data-slot=kbd-group])]:pe-1 [&>*:has(>[data-slot=kbd])]:pe-1 [&>[data-current]]:w-(--popup-width) [&>[data-previous]]:top-0 [&>[data-previous]]:start-0 [&>[data-current]]:transition-[translate,opacity,filter] [&>[data-previous]]:transition-[translate,opacity,filter] [&>[data-current]]:duration-250 [&>[data-previous]]:duration-150 [&>[data-current]]:ease-out-quint [&>[data-previous]]:ease-out-quint [&>[data-current][data-starting-style]]:opacity-0 [&>[data-previous][data-ending-style]]:opacity-0 motion-safe:[&>[data-current][data-starting-style]]:blur-[1px] motion-safe:[&>[data-previous][data-ending-style]]:blur-[1px] motion-safe:data-[activation-direction~=right]:[&>[data-current][data-starting-style]]:translate-x-4 motion-safe:data-[activation-direction~=right]:[&>[data-previous][data-ending-style]]:-translate-x-4 motion-safe:data-[activation-direction~=left]:[&>[data-current][data-starting-style]]:-translate-x-4 motion-safe:data-[activation-direction~=left]:[&>[data-previous][data-ending-style]]:translate-x-4 motion-safe:data-[activation-direction~=down]:[&>[data-current][data-starting-style]]:translate-y-2 motion-safe:data-[activation-direction~=down]:[&>[data-previous][data-ending-style]]:-translate-y-2 motion-safe:data-[activation-direction~=up]:[&>[data-current][data-starting-style]]:-translate-y-2 motion-safe:data-[activation-direction~=up]:[&>[data-previous][data-ending-style]]:translate-y-2 motion-reduce:[&>*]:transition-opacity data-instant:[&>*]:transition-none" function freezeOutgoingWidth(viewport: HTMLDivElement | null) { if (!viewport || typeof MutationObserver === "undefined") { return } let currentWidth = 0 const sizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver((entries) => { for (const entry of entries) { if ((entry.target as HTMLElement).hasAttribute("data-current")) { currentWidth = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width } } }) const sync = () => { for (const child of Array.from(viewport.children)) { const element = child as HTMLElement sizes?.observe(element) if (!element.hasAttribute("data-previous")) { element.style.removeProperty("width") delete element.dataset.frozen continue } const key = element.textContent ?? "" if (element.dataset.frozen !== key) { element.dataset.frozen = key const width = currentWidth || viewport.getBoundingClientRect().width element.style.width = `${width}px` } } } sync() const observer = new MutationObserver(sync) observer.observe(viewport, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["data-current", "data-previous"], }) return () => { observer.disconnect() sizes?.disconnect() } } function TooltipArrow({ className, ...props }: TooltipPrimitive.Arrow.Props) { return ( <TooltipPrimitive.Arrow data-slot="tooltip-arrow" className={mergeClassName( "h-1.5 w-3 overflow-clip transition-[left,top] duration-250 ease-out-quint before:absolute before:bottom-0 before:left-1/2 before:size-[calc(var(--spacing)*1.5*sqrt(2))] before:-translate-x-1/2 before:translate-y-1/2 before:rotate-45 before:rounded-[1px] before:bg-foreground data-instant:transition-none data-[side=bottom]:-top-1.5 data-[side=inline-end]:-left-[9px] data-[side=inline-end]:-rotate-90 data-[side=inline-start]:-right-[9px] data-[side=inline-start]:rotate-90 data-[side=left]:-right-[9px] data-[side=left]:rotate-90 data-[side=right]:-left-[9px] data-[side=right]:-rotate-90 data-[side=top]:-bottom-1.5 data-[side=top]:rotate-180 motion-reduce:transition-none rtl:data-[side=inline-end]:right-[-9px] rtl:data-[side=inline-end]:left-auto rtl:data-[side=inline-end]:rotate-90 rtl:data-[side=inline-start]:right-auto rtl:data-[side=inline-start]:left-[-9px] rtl:data-[side=inline-start]:-rotate-90", className )} {...props} /> ) } type TooltipGroupProps = TooltipPrimitive.Provider.Props & Omit<TooltipContentProps, "children"> & { children?: React.ReactNode } function TooltipGroup({ delay, closeDelay = 100, timeout, children, ...contentProps }: TooltipGroupProps) { const [handle] = React.useState(() => TooltipPrimitive.createHandle<React.ReactNode>() ) return ( <TooltipProvider delay={delay} closeDelay={closeDelay} timeout={timeout}> <TooltipGroupContext.Provider value={handle}> {children} </TooltipGroupContext.Provider> <Tooltip handle={handle}> {({ payload }) => ( <TooltipContent {...contentProps}> {payload as React.ReactNode} </TooltipContent> )} </Tooltip> </TooltipProvider> ) } const createTooltipHandle = TooltipPrimitive.createHandle export { TooltipProvider, Tooltip, TooltipGroup, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, createTooltipHandle, } export type { TooltipContentProps, TooltipGroupProps, TooltipTriggerProps }hooks/use-composed-ref.ts import * as React from "react" function useComposedRef<T>(ref: React.Ref<T> | undefined) { const inner = React.useRef<T | null>(null) const setRef = React.useCallback( (node: T | null) => { inner.current = node if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } }, [ref] ) return [inner, setRef] as const } export { useComposedRef }lib/hotkey.ts import * as React from "react" function subscribePlatform() { return () => {} } function isApplePlatform() { if (typeof navigator === "undefined") { return true } return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent) } function useIsApple() { return React.useSyncExternalStore( subscribePlatform, isApplePlatform, () => true ) } const modifierOrder = ["ctrl", "alt", "shift", "mod", "meta"] const appleKeys: Record<string, string> = { mod: "⌘", meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧", enter: "↵", backspace: "⌫", escape: "esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→", } const otherKeys: Record<string, string> = { mod: "Ctrl", meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift", enter: "Enter", backspace: "Backspace", escape: "Esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→", } const sharedKeys: Record<string, string> = { space: "Space", tab: "Tab", delete: "Del", home: "Home", end: "End", pageup: "PgUp", pagedown: "PgDn", } const spokenKeys: Record<string, string> = { mod: "Command", meta: "Command", ctrl: "Control", alt: "Option", shift: "Shift", enter: "Enter", backspace: "Delete", escape: "Escape", arrowup: "Up arrow", arrowdown: "Down arrow", arrowleft: "Left arrow", arrowright: "Right arrow", } const otherSpokenKeys: Record<string, string> = { mod: "Control", meta: "Windows", alt: "Alt", backspace: "Backspace", } function normalizeKey(token: string) { const key = token.trim().toLowerCase() const aliases: Record<string, string> = { cmd: "meta", command: "meta", control: "ctrl", option: "alt", opt: "alt", return: "enter", esc: "escape", up: "arrowup", down: "arrowdown", left: "arrowleft", right: "arrowright", " ": "space", } return aliases[key] ?? key } function sortModifiers(parts: string[]) { const key = parts.at(-1) ?? "" const modifiers = parts .slice(0, -1) .sort((a, b) => modifierOrder.indexOf(a) - modifierOrder.indexOf(b)) return [...modifiers, key] } function parseHotkey(hotkey: string) { return hotkey .trim() .split(/\s+/) .filter(Boolean) .map((chord) => sortModifiers( chord .split(/\+(?!$)/) .map(normalizeKey) .filter(Boolean) ) ) } function keyLabel(key: string, apple: boolean) { const names = apple ? appleKeys : otherKeys return ( names[key] ?? sharedKeys[key] ?? (key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1)) ) } function spokenKey(key: string, apple: boolean) { return ( (!apple ? otherSpokenKeys[key] : undefined) ?? spokenKeys[key] ?? sharedKeys[key] ?? keyLabel(key, apple) ) } function parseChord(hotkey: string) { return parseHotkey(hotkey.replace(/\s+/g, ""))[0] ?? [] } function formatHotkey(hotkey: string, apple: boolean) { const labels = parseChord(hotkey).map((part) => keyLabel(part, apple)) return apple ? labels.join("") : labels.join("+") } function eventKeys(event: KeyboardEvent) { const keys = new Set([normalizeKey(event.key === " " ? "space" : event.key)]) const physical = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(event.code ?? "") if (physical) { keys.add((physical[1] ?? physical[2]).toLowerCase()) } return keys } function matchesHotkey(event: KeyboardEvent, hotkey: string) { const parts = parseChord(hotkey) const key = parts.pop() const wants = new Set(parts) const apple = isApplePlatform() const meta = wants.has("meta") || (wants.has("mod") && apple) const ctrl = wants.has("ctrl") || (wants.has("mod") && !apple) return ( key !== undefined && eventKeys(event).has(key) && event.metaKey === meta && event.ctrlKey === ctrl && event.altKey === wants.has("alt") && event.shiftKey === wants.has("shift") ) } export { formatHotkey, isApplePlatform, keyLabel, matchesHotkey, parseHotkey, spokenKey, useIsApple, } - 4
Update the import paths to match your project setup.
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"Wrap your layout in <SidebarProvider /> and put the page in <SidebarInset />, after the sidebar.
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SidebarTrigger />
{children}
</SidebarInset>
</SidebarProvider>
)
}export function AppSidebar() {
return (
<Sidebar>
<SidebarHeader />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<a href="/" />} isActive>
<IconHome />
<span>Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
</Sidebar>
)
}SidebarProvider
├── Sidebar
│ ├── SidebarHeader
│ ├── SidebarContent
│ │ └── SidebarGroup
│ │ ├── SidebarGroupLabel
│ │ ├── SidebarGroupAction
│ │ └── SidebarGroupContent
│ │ └── SidebarMenu
│ │ └── SidebarMenuItem
│ │ ├── SidebarMenuButton
│ │ ├── SidebarMenuAction
│ │ ├── SidebarMenuBadge
│ │ └── SidebarMenuSub
│ │ └── SidebarMenuSubItem
│ │ └── SidebarMenuSubButton
│ ├── SidebarFooter
│ └── SidebarRail
└── SidebarInset
└── SidebarTriggerSidebarProviderholds the open state, the keyboard shortcut and the widths.Sidebaris a column that stays pinned while the page scrolls. Below 768px it becomes a sheet.SidebarHeaderandSidebarFooterstay in place.SidebarContentscrolls between them.SidebarGroupis a section with an optional label and action.SidebarMenuholds the links.SidebarInsetis the page next to the sidebar.
Variants#
variant sets the look: a full-height sidebar, a floating panel, or inset, where the page becomes a card on the sidebar color. Switch between them to see the layout animate.
"use client"
import * as React from "react"
import {
IconHome,
IconInbox,
IconSearch,
IconSettings,
} from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type Variant = "sidebar" | "floating" | "inset"
const items = [
{ title: "Home", icon: IconHome },
{ title: "Inbox", icon: IconInbox },
{ title: "Search", icon: IconSearch },
{ title: "Settings", icon: IconSettings },
]
export function SidebarVariants() {
const [variant, setVariant] = React.useState<Variant>("inset")
const [active, setActive] = React.useState("Home")
return (
<SidebarProvider className="h-112 min-h-0">
<Sidebar variant={variant}>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={active === item.title}
onClick={() => setActive(item.title)}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-14 items-center gap-2 px-4">
<SidebarTrigger className="-ms-1" />
<ToggleGroup
aria-label="Variant"
value={[variant]}
onValueChange={(value) => {
if (value[0]) {
setVariant(value[0] as Variant)
}
}}
>
<ToggleGroupItem value="sidebar">Sidebar</ToggleGroupItem>
<ToggleGroupItem value="floating">Floating</ToggleGroupItem>
<ToggleGroupItem value="inset">Inset</ToggleGroupItem>
</ToggleGroup>
</header>
</SidebarInset>
</SidebarProvider>
)
}Collapsible#
offcanvas slides the sidebar out of view, icon shrinks it to its icons and shows each label in a tooltip, and none keeps it open. Press ⌘B or Ctrl+B to toggle the sidebar you are working in.
"use client"
import * as React from "react"
import {
IconHome,
IconInbox,
IconSearch,
IconSettings,
} from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type CollapsibleMode = "offcanvas" | "icon" | "none"
const items = [
{ title: "Home", icon: IconHome },
{ title: "Inbox", icon: IconInbox },
{ title: "Search", icon: IconSearch },
{ title: "Settings", icon: IconSettings },
]
export function SidebarCollapsible() {
const [collapsible, setCollapsible] = React.useState<CollapsibleMode>("icon")
const [active, setActive] = React.useState("Home")
return (
<SidebarProvider className="h-112 min-h-0">
<Sidebar collapsible={collapsible}>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={active === item.title}
onClick={() => setActive(item.title)}
tooltip={item.title}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-14 items-center gap-2 px-4">
<SidebarTrigger className="-ms-1" />
<ToggleGroup
aria-label="Collapsible"
value={[collapsible]}
onValueChange={(value) => {
if (value[0]) {
setCollapsible(value[0] as CollapsibleMode)
}
}}
>
<ToggleGroupItem value="offcanvas">Offcanvas</ToggleGroupItem>
<ToggleGroupItem value="icon">Icon</ToggleGroupItem>
<ToggleGroupItem value="none">None</ToggleGroupItem>
</ToggleGroup>
</header>
</SidebarInset>
</SidebarProvider>
)
}Collapsible groups and submenus#
Wrap a SidebarGroup or a SidebarMenuItem in a Collapsible, render the label or button as the trigger, and nest a SidebarMenuSub.
import {
IconChevronDown,
IconChevronRight,
IconFile,
IconFolder,
} from "@tabler/icons-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
const sections = [
{
title: "Getting started",
items: ["Installation", "Project structure", "Deploying"],
},
{
title: "Building your app",
items: ["Routing", "Data fetching", "Rendering", "Caching", "Styling"],
},
{
title: "API reference",
items: ["Components", "File conventions", "Functions"],
},
]
export function SidebarSubmenus() {
return (
<SidebarProvider className="h-128 min-h-0">
<Sidebar>
<SidebarContent>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel render={<CollapsibleTrigger />}>
Documentation
<IconChevronDown className="ms-auto duration-200 group-data-open/collapsible:rotate-180 motion-safe:transition-transform" />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{sections.map((section, index) => (
<Collapsible
key={section.title}
defaultOpen={index === 1}
render={<SidebarMenuItem />}
className="group/section"
>
<CollapsibleTrigger render={<SidebarMenuButton />}>
<IconFolder />
<span>{section.title}</span>
<IconChevronRight className="ms-auto duration-200 group-data-open/section:rotate-90 motion-safe:transition-transform rtl:-scale-x-100" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{section.items.map((item) => (
<SidebarMenuSubItem key={item}>
<SidebarMenuSubButton
href="#"
isActive={item === "Data fetching"}
>
<IconFile />
<span>{item}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-14 items-center px-4">
<SidebarTrigger className="-ms-1" />
</header>
</SidebarInset>
</SidebarProvider>
)
}Right side#
Set side="right" and place the sidebar after SidebarInset. The rail and the mobile sheet follow the side.
- 3
- 12
- 128
import { IconAt, IconBell, IconMessage, IconUsers } from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"
const items = [
{ title: "Mentions", icon: IconAt, count: 3 },
{ title: "Comments", icon: IconMessage, count: 12 },
{ title: "Reminders", icon: IconBell, count: 0 },
{ title: "Followers", icon: IconUsers, count: 128 },
]
export function SidebarRight() {
return (
<SidebarProvider className="h-112 min-h-0">
<SidebarInset>
<header className="flex h-14 items-center justify-end px-4">
<SidebarTrigger className="-me-1" />
</header>
</SidebarInset>
<Sidebar side="right">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Activity</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.count > 0 && (
<SidebarMenuBadge>{item.count}</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</SidebarProvider>
)
}Under a header#
The sidebar is sticky, so it starts below anything above it. With a sticky header, set --sidebar-top to its height and the sidebar pins below it and fits the rest of the screen.
v2.4.0
import { IconHome, IconInbox, IconSettings } from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
const items = [
{ title: "Home", icon: IconHome },
{ title: "Inbox", icon: IconInbox },
{ title: "Settings", icon: IconSettings },
]
export function SidebarHeaderLayout() {
return (
<SidebarProvider className="h-112 min-h-0 flex-col [--sidebar-top:3.5rem]">
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ms-1" />
<span className="text-sm font-medium">Acme</span>
</header>
<div className="flex min-h-0 flex-1">
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<p className="px-2 text-xs text-muted-foreground">v2.4.0</p>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="min-h-32 flex-1 rounded-xl bg-muted" />
</div>
</SidebarInset>
</div>
</SidebarProvider>
)
}Controlled#
Pass open and onOpenChange. The trigger, the rail and the shortcut all go through onOpenChange.
"use client"
import * as React from "react"
import { IconHome, IconInbox, IconSettings } from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar"
import { Switch } from "@/components/ui/switch"
const items = [
{ title: "Home", icon: IconHome },
{ title: "Inbox", icon: IconInbox },
{ title: "Settings", icon: IconSettings },
]
export function SidebarControlled() {
const [open, setOpen] = React.useState(true)
return (
<SidebarProvider
open={open}
onOpenChange={setOpen}
className="h-96 min-h-0"
>
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<div className="flex items-center gap-3 p-4">
<Switch
id="sidebar-controlled-open"
checked={open}
onCheckedChange={setOpen}
/>
<label htmlFor="sidebar-controlled-open" className="text-sm">
Sidebar {open ? "expanded" : "collapsed"}
</label>
</div>
</SidebarInset>
</SidebarProvider>
)
}Loading#
SidebarMenuSkeleton fills a menu while it loads. Widths vary per row and match between server and client.
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
export function SidebarSkeleton() {
return (
<SidebarProvider className="h-96 min-h-0">
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array.from({ length: 6 }).map((_, index) => (
<SidebarMenuItem key={index}>
<SidebarMenuSkeleton showIcon />
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header className="flex h-14 items-center px-4">
<SidebarTrigger className="-ms-1" />
</header>
</SidebarInset>
</SidebarProvider>
)
}Right to left#
Set dir="rtl" and side="right". Spacing, submenu lines, tooltips and the trigger icon mirror.
- ٢٤
import { IconHome, IconInbox, IconSettings } from "@tabler/icons-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"
const items = [
{ title: "الرئيسية", icon: IconHome },
{ title: "البريد الوارد", icon: IconInbox, badge: "٢٤" },
{ title: "الإعدادات", icon: IconSettings },
]
export function SidebarRtl() {
return (
<SidebarProvider dir="rtl" className="h-96 min-h-0">
<Sidebar side="right" collapsible="icon" dir="rtl">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>التطبيق</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item, index) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={index === 0}
tooltip={item.title}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge && (
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
<SidebarInset>
<header className="flex h-14 items-center px-4">
<SidebarTrigger className="-ms-1" />
</header>
</SidebarInset>
</SidebarProvider>
)
}The sidebar is 16rem wide, 18rem on phones and 3rem when collapsed to icons. Override --sidebar-width, --sidebar-width-mobile and --sidebar-width-icon on the provider.
<SidebarProvider
style={
{
"--sidebar-width": "20rem",
"--sidebar-width-mobile": "20rem",
} as React.CSSProperties
}
>The sidebar has no side effects. To remember whether it was open, save it in onOpenChange and read it back into defaultOpen when rendering on the server.
"use client"
export function Providers({
defaultOpen,
children,
}: {
defaultOpen: boolean
children: React.ReactNode
}) {
return (
<SidebarProvider
defaultOpen={defaultOpen}
onOpenChange={(open) => {
document.cookie = `sidebar_state=${open}; path=/; max-age=604800`
}}
>
{children}
</SidebarProvider>
)
}| Key | Action |
|---|---|
| ⌘ + BCtrl + B | Toggles the sidebar. With several sidebars on a page, the one holding focus responds, otherwise the first. Ignored while typing in a rich text editor. |
| TabShift + Tab | Moves through the links. A collapsed off-canvas sidebar is skipped. |
| EnterSpace | Activates the focused link, button or trigger. |
| Esc | Closes the sidebar on phones. |
SidebarTriggeris labelled “Toggle Sidebar” and exposesaria-expandedandaria-controls.isActivesetsaria-current="page".- Collapsing off canvas hides the content from keyboard and screen readers. If focus was inside, it moves to the trigger.
- Collapsed to icons, labels stay in each link's accessible name and show as a tooltip on hover and keyboard focus. Group labels, actions and badges are hidden.
- On phones the sidebar is a modal sheet: focus is trapped, swiping or Esc closes it, and following a link closes it. Links that open a new tab, downloads and modified clicks keep it open.
- With reduced motion enabled, collapsing is instant.
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | true |
open | boolean | – |
onOpenChange | (open: boolean) => void | – |
keyboardShortcutmod is ⌘ on Apple platforms and Ctrl elsewhere. null turns it off. | string | null | "mod+b" |
| Attribute | Description |
|---|---|
data-slot="sidebar-wrapper" | The layout wrapper. |
--sidebar-width | Expanded width. Defaults to 16rem. |
--sidebar-width-mobile | Width of the phone sheet. Defaults to 18rem. |
--sidebar-width-icon | Width collapsed to icons. Defaults to 3rem. |
--sidebar-top | Where the sidebar pins while scrolling, such as below a sticky header. Defaults to 0px. |
className and other props go to the sidebar container.
| Prop | Type | Default |
|---|---|---|
side | "left" | "right" | "left" |
variantplain drops the surface, the edge line, the scrollbar and the gap between menu items, for a sidebar that sits on the page. | "sidebar" | "floating" | "inset" | "plain" | "sidebar" |
collapsible | "offcanvas" | "icon" | "none" | "offcanvas" |
dirAlso sets the direction of the phone sheet. | "ltr" | "rtl" | – |
| Attribute | Description |
|---|---|
data-slot="sidebar" | The sidebar, or the sheet on phones. |
data-state | "expanded" or "collapsed". |
data-collapsible | The collapsible mode while collapsed, otherwise "". Style children with group-data-[collapsible=icon]:. |
data-variant | The variant. |
data-side | The side. |
data-mobile | Present on the phone sheet. |
data-slot="sidebar-container" | The panel that slides and resizes. |
data-slot="sidebar-inner" | The surface that holds the content. |
A ghost icon Button that calls toggleSidebar. Call event.preventDefault() in onClick to stop it. Pass children to replace the icon.
| Attribute | Description |
|---|---|
data-slot="sidebar-trigger" | The trigger. |
aria-expanded | Whether the sidebar is open. |
A thin hit area on the sidebar edge that toggles it on click. It is left out of the tab order because the trigger and shortcut cover keyboard users. When the sidebar is off canvas, the rail stays at the screen edge.
| Attribute | Description |
|---|---|
data-slot="sidebar-rail" | The rail. |
A <main> that takes the rest of the width. Next to an inset sidebar it becomes a rounded card.
| Prop | Type | Default |
|---|---|---|
renderRenders a different element. Pass a <div /> when the page already has a <main> landmark. | React.ReactElement | (props) => React.ReactElement | – |
| Attribute | Description |
|---|---|
data-slot="sidebar-inset" | The page area. |
Plain <div> elements. The content scrolls with a thin scrollbar and soft edge fades.
| Attribute | Description |
|---|---|
data-slot="sidebar-header" | Top section. |
data-slot="sidebar-content" | Scrollable middle. |
data-slot="sidebar-footer" | Bottom section. |
| Attribute | Description |
|---|---|
data-slot="sidebar-group" | A section of the sidebar. |
data-slot="sidebar-group-content" | The group's content. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="sidebar-group-label" | Slides up and fades when collapsed to icons. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="sidebar-group-action" | Needs an accessible label. |
A <ul> and its <li> items.
| Attribute | Description |
|---|---|
data-slot="sidebar-menu" | The list. |
data-slot="sidebar-menu-item" | An item. group/menu-item for hover styles. |
| Prop | Type | Default |
|---|---|---|
isActiveHighlights it and sets aria-current="page". | boolean | false |
variant | "default" | "outline" | "default" |
size | "default" | "sm" | "lg" | "default" |
tooltipShown while collapsed to icons. Content slides between items as you move along the menu. | ReactNode | TooltipContentProps | – |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="sidebar-menu-button" | peer/menu-button for sibling styles. |
data-active | Present while isActive. |
data-size | The size. |
| Prop | Type | Default |
|---|---|---|
showOnHoverShows it only while the item is hovered or focused, or its menu is open. Always visible on touch screens. | boolean | false |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="sidebar-menu-action" | Needs an accessible label. |
| Attribute | Description |
|---|---|
data-slot="sidebar-menu-badge" | A count at the end of the item. |
| Prop | Type | Default |
|---|---|---|
showIcon | boolean | false |
| Attribute | Description |
|---|---|
data-slot="sidebar-menu-skeleton" | Hidden from assistive tech. |
A nested list with a line on its leading edge. It folds away when the sidebar collapses to icons.
| Prop | Type | Default |
|---|---|---|
isActiveOn SidebarMenuSubButton. | boolean | false |
sizeOn SidebarMenuSubButton. | "sm" | "md" | "md" |
renderOn SidebarMenuSubButton. | ReactElement | (props, state) => ReactElement | <a> |
| Attribute | Description |
|---|---|
data-slot="sidebar-menu-sub" | The nested list. |
data-slot="sidebar-menu-sub-button" | A nested link. |
data-active | Present while isActive. |
A small Input on the page background, and a hairline separator.
| Attribute | Description |
|---|---|
data-slot="sidebar-input" | The input. |
data-slot="sidebar-separator" | The separator. |
Reads and controls the nearest sidebar. Throws outside SidebarProvider.
const {
state,
open,
setOpen,
openMobile,
setOpenMobile,
isMobile,
toggleSidebar,
} = useSidebar()| Prop | Type | Default |
|---|---|---|
state | "expanded" | "collapsed" | – |
open | boolean | – |
setOpen | (open: boolean) => void | – |
openMobile | boolean | – |
setOpenMobile | (open: boolean) => void | – |
isMobile | boolean | – |
toggleSidebarToggles the sheet on phones, else the sidebar. | () => void | – |