# Sidebar

> An app sidebar that collapses to icons or off canvas, stays pinned under your header, and becomes a swipeable sheet on phones.

Docs: https://hextaui.com/docs/sidebar
Markdown: https://hextaui.com/docs/sidebar.md

```tsx title="components/examples/sidebar/demo.tsx"
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">
                      olivia@acme.com
                    </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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/sidebar.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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,
}
```

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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,
}
```

```tsx title="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 }
```

```tsx title="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 }
```

```ts title="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 }
```

```ts title="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,
}
```

Update the import paths to match your project setup.

## Usage

```tsx
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.

```tsx
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <AppSidebar />
      <SidebarInset>
        <SidebarTrigger />
        {children}
      </SidebarInset>
    </SidebarProvider>
  )
}
```

```tsx
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>
  )
}
```

## Composition

```text
SidebarProvider
├── Sidebar
│   ├── SidebarHeader
│   ├── SidebarContent
│   │   └── SidebarGroup
│   │       ├── SidebarGroupLabel
│   │       ├── SidebarGroupAction
│   │       └── SidebarGroupContent
│   │           └── SidebarMenu
│   │               └── SidebarMenuItem
│   │                   ├── SidebarMenuButton
│   │                   ├── SidebarMenuAction
│   │                   ├── SidebarMenuBadge
│   │                   └── SidebarMenuSub
│   │                       └── SidebarMenuSubItem
│   │                           └── SidebarMenuSubButton
│   ├── SidebarFooter
│   └── SidebarRail
└── SidebarInset
    └── SidebarTrigger
```

- `SidebarProvider` holds the open state, the keyboard shortcut and the widths.
- `Sidebar` is a column that stays pinned while the page scrolls. Below 768px it becomes a sheet.
- `SidebarHeader` and `SidebarFooter` stay in place. `SidebarContent` scrolls between them.
- `SidebarGroup` is a section with an optional label and action. `SidebarMenu` holds the links.
- `SidebarInset` is the page next to the sidebar.

## Examples

### 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.

```tsx title="components/examples/sidebar/variants.tsx"
"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.

```tsx title="components/examples/sidebar/collapsible.tsx"
"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`.

```tsx title="components/examples/sidebar/submenus.tsx"
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.

```tsx title="components/examples/sidebar/right.tsx"
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.

```tsx title="components/examples/sidebar/header.tsx"
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`.

```tsx title="components/examples/sidebar/controlled.tsx"
"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.

```tsx title="components/examples/sidebar/skeleton.tsx"
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.

```tsx title="components/examples/sidebar/rtl.tsx"
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>
  )
}
```

## Width

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.

```tsx
<SidebarProvider
  style={
    {
      "--sidebar-width": "20rem",
      "--sidebar-width-mobile": "20rem",
    } as React.CSSProperties
  }
>
```

## Persisting the state

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.

```tsx
"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>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `⌘ + B` `Ctrl + 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. |
| `Tab` `Shift + Tab` | Moves through the links. A collapsed off-canvas sidebar is skipped. |
| `Enter` `Space` | Activates the focused link, button or trigger. |
| `Esc` | Closes the sidebar on phones. |

## Accessibility

- `SidebarTrigger` is labelled “Toggle Sidebar” and exposes `aria-expanded` and `aria-controls`.
- `isActive` sets `aria-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.

## API reference

### SidebarProvider

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `true` |  |
| `open` | `boolean` | – |  |
| `onOpenChange` | `(open: boolean) => void` | – |  |
| `keyboardShortcut` | `string \| null` | `"mod+b"` | mod is ⌘ on Apple platforms and Ctrl elsewhere. null turns it off. |

| 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. |

### Sidebar

`className` and other props go to the sidebar container.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"left" \| "right"` | `"left"` |  |
| `variant` | `"sidebar" \| "floating" \| "inset" \| "plain"` | `"sidebar"` | plain drops the surface, the edge line, the scrollbar and the gap between menu items, for a sidebar that sits on the page. |
| `collapsible` | `"offcanvas" \| "icon" \| "none"` | `"offcanvas"` |  |
| `dir` | `"ltr" \| "rtl"` | – | Also sets the direction of the phone sheet. |

| 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. |

### SidebarTrigger

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. |

### SidebarRail

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. |

### SidebarInset

A `<main>` that takes the rest of the width. Next to an `inset` sidebar it becomes a rounded card.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `React.ReactElement \| (props) => React.ReactElement` | – | Renders a different element. Pass a <div /> when the page already has a <main> landmark. |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-inset"` | The page area. |

### SidebarHeader, SidebarContent, SidebarFooter

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. |

### SidebarGroup, SidebarGroupContent

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-group"` | A section of the sidebar. |
| `data-slot="sidebar-group-content"` | The group's content. |

### SidebarGroupLabel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-group-label"` | Slides up and fades when collapsed to icons. |

### SidebarGroupAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-group-action"` | Needs an accessible label. |

### SidebarMenu, SidebarMenuItem

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. |

### SidebarMenuButton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Highlights it and sets aria-current="page". |
| `variant` | `"default" \| "outline"` | `"default"` |  |
| `size` | `"default" \| "sm" \| "lg"` | `"default"` |  |
| `tooltip` | `ReactNode \| TooltipContentProps` | – | Shown while collapsed to icons. Content slides between items as you move along the menu. |
| `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. |

### SidebarMenuAction

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showOnHover` | `boolean` | `false` | Shows it only while the item is hovered or focused, or its menu is open. Always visible on touch screens. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-menu-action"` | Needs an accessible label. |

### SidebarMenuBadge

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-menu-badge"` | A count at the end of the item. |

### SidebarMenuSkeleton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showIcon` | `boolean` | `false` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-menu-skeleton"` | Hidden from assistive tech. |

### SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton

A nested list with a line on its leading edge. It folds away when the sidebar collapses to icons.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | On SidebarMenuSubButton. |
| `size` | `"sm" \| "md"` | `"md"` | On SidebarMenuSubButton. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<a>` | On SidebarMenuSubButton. |

| Attribute | Description |
| --- | --- |
| `data-slot="sidebar-menu-sub"` | The nested list. |
| `data-slot="sidebar-menu-sub-button"` | A nested link. |
| `data-active` | Present while isActive. |

### SidebarInput, SidebarSeparator

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. |

### useSidebar

Reads and controls the nearest sidebar. Throws outside `SidebarProvider`.

```tsx
const {
  state,
  open,
  setOpen,
  openMobile,
  setOpenMobile,
  isMobile,
  toggleSidebar,
} = useSidebar()
```

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `"expanded" \| "collapsed"` | – |  |
| `open` | `boolean` | – |  |
| `setOpen` | `(open: boolean) => void` | – |  |
| `openMobile` | `boolean` | – |  |
| `setOpenMobile` | `(open: boolean) => void` | – |  |
| `isMobile` | `boolean` | – |  |
| `toggleSidebar` | `() => void` | – | Toggles the sheet on phones, else the sidebar. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
