{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidebar",
  "title": "Sidebar",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An app sidebar with icon-rail collapse, floating and inset variants, grouped menus with badges and sub-items, a mobile sheet, tooltips when collapsed and a Cmd/Ctrl+B shortcut.",
  "dependencies": [
    "class-variance-authority",
    "radix-ui",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/sheet.json",
    "https://ui.ballmac.com/r/tooltip.json",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/sidebar.tsx",
      "content": "// Ballmac UI: Sidebar. https://ui.ballmac.com/components/sidebar\n// Based on shadcn/ui Sidebar (MIT, Copyright (c) 2023 shadcn), trimmed and restyled: theme-token surfaces, links without asChild, a mobile sheet with a named dialog, tooltips only while collapsed, and reduced-motion-safe width transitions.\n\"use client\";\n\nimport * as React from \"react\";\nimport { cva } from \"class-variance-authority\";\nimport { PanelLeft } from \"lucide-react\";\nimport { Slot } from \"radix-ui\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetTitle,\n} from \"@/components/ballmac/sheet\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ballmac/tooltip\";\nimport { useDirection } from \"@/lib/ballmac/direction\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\nconst MOBILE_QUERY = \"(max-width: 767px)\";\n\nfunction subscribeMobile(callback: () => void) {\n  const query = window.matchMedia(MOBILE_QUERY);\n  query.addEventListener(\"change\", callback);\n  return () => query.removeEventListener(\"change\", callback);\n}\n/** True below 768px. Returns false on the server so the desktop layout renders first. */\nfunction useIsMobile() {\n  return React.useSyncExternalStore(\n    subscribeMobile,\n    () => window.matchMedia(MOBILE_QUERY).matches,\n    () => false,\n  );\n}\n\ntype SidebarContextValue = {\n  state: \"expanded\" | \"collapsed\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n};\nconst SidebarContext = React.createContext<SidebarContextValue | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) throw new Error(\"useSidebar must be used within a <SidebarProvider>\");\n  return context;\n}\n\ntype SidebarProviderProps = React.ComponentProps<\"div\"> & {\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  /** Controlled open state (desktop). */\n  open?: boolean;\n  /** Called when the desktop sidebar opens or closes, for example to save the choice in a cookie. */\n  onOpenChange?: (open: boolean) => void;\n  /** Key that toggles the sidebar together with ⌘ (Ctrl on Windows and Linux). `false` turns the shortcut off. */\n  shortcut?: string | false;\n};\n\n/** Holds the open state, the mobile sheet state and the ⌘B shortcut. Wrap the sidebar and the page content in it. */\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange,\n  shortcut = \"b\",\n  className,\n  style,\n  children,\n  ...props\n}: SidebarProviderProps) {\n  const isMobile = useIsMobile();\n  const [openMobile, setOpenMobile] = React.useState(false);\n  const [innerOpen, setInnerOpen] = React.useState(defaultOpen);\n  const open = openProp ?? innerOpen;\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInnerOpen(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n  const toggleSidebar = React.useCallback(\n    () => (isMobile ? setOpenMobile((v) => !v) : setOpen(!open)),\n    [isMobile, open, setOpen],\n  );\n  React.useEffect(() => {\n    if (!shortcut) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === shortcut.toLowerCase()) {\n        event.preventDefault();\n        toggleSidebar();\n      }\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [shortcut, toggleSidebar]);\n\n  const value = React.useMemo<SidebarContextValue>(\n    () => ({ state: open ? \"expanded\" : \"collapsed\", open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar }),\n    [open, setOpen, openMobile, isMobile, toggleSidebar],\n  );\n  return (\n    <SidebarContext.Provider value={value}>\n      <div\n        data-slot=\"sidebar-wrapper\"\n        style={{ \"--sidebar-width\": \"16rem\", \"--sidebar-width-icon\": \"3.5rem\", ...style } as React.CSSProperties}\n        className={cn(\"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-muted/40\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    </SidebarContext.Provider>\n  );\n}\n\ntype SidebarProps = React.ComponentProps<\"div\"> & {\n  /** Which edge the sidebar sits on. `start` (the default) and `end` follow the reading direction; `left` and `right` are fixed. */\n  side?: \"start\" | \"end\" | \"left\" | \"right\";\n  /** `sidebar` is flush, `floating` is a rounded card with a gap, `inset` pairs with `SidebarInset`. */\n  variant?: \"sidebar\" | \"floating\" | \"inset\";\n  /** `icon` collapses to an icon rail, `offcanvas` slides away, `none` is always open. */\n  collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n  /** Accessible name of the navigation landmark (and of the mobile sheet). */\n  label?: string;\n};\n\nfunction Sidebar({\n  side = \"start\",\n  variant = \"sidebar\",\n  collapsible = \"icon\",\n  label,\n  className,\n  children,\n  ...props\n}: SidebarProps) {\n  const msg = useMessages()\n  label ??= msg(\"sidebar.label\", \"Sidebar\")\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n  if (collapsible === \"none\") {\n    return (\n      <div\n        data-slot=\"sidebar\"\n        className={cn(\"flex h-full w-(--sidebar-width) flex-col border-e bg-card text-card-foreground\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    );\n  }\n  if (isMobile) {\n    return (\n      <Sheet open={openMobile} onOpenChange={setOpenMobile}>\n        <SheetContent\n          side={side}\n          showCloseButton={false}\n          data-slot=\"sidebar\"\n          data-mobile=\"true\"\n          className=\"w-72 gap-0 bg-card p-0 sm:w-72 [&>button]:hidden\"\n        >\n          <SheetTitle className=\"sr-only\">{label}</SheetTitle>\n          <SheetDescription className=\"sr-only\">{msg(\"sidebar.navigation\", \"Navigation\")}</SheetDescription>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n  return (\n    <div\n      className=\"group peer hidden text-card-foreground md:block\"\n      data-state={state}\n      data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n      data-variant={variant}\n      data-side={side}\n      data-slot=\"sidebar\"\n    >\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear motion-reduce:transition-none\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\",\n        )}\n      />\n      <div\n        className={cn(\n          \"fixed inset-y-0 z-20 hidden h-svh w-(--sidebar-width) transition-[inset-inline-start,inset-inline-end,left,right,width] duration-200 ease-linear motion-reduce:transition-none md:flex\",\n          {\n            start: \"start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]\",\n            end: \"end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]\",\n            left: \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\", // rtl-fixed: fixed left edge\n            right: \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\", // rtl-fixed: fixed right edge\n          }[side],\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=start]:border-e group-data-[side=end]:border-s group-data-[side=left]:border-r group-data-[side=right]:border-l\", // rtl-fixed: left and right sidebars are fixed edges\n          className,\n        )}\n        {...props}\n      >\n        <nav\n          aria-label={label}\n          data-sidebar=\"sidebar\"\n          className={cn(\n            \"flex h-full w-full flex-col bg-card\",\n            variant === \"floating\" && \"rounded-xl border shadow-sm\",\n            variant === \"inset\" && \"rounded-xl\",\n          )}\n        >\n          {children}\n        </nav>\n      </div>\n    </div>\n  );\n}\n\ntype SidebarTriggerProps = React.ComponentProps<\"button\">;\n/** Toggles the sidebar (the sheet on small screens). Announces the state with `aria-expanded`. */\nfunction SidebarTrigger({ className, onClick, ...props }: SidebarTriggerProps) {\n  const msg = useMessages()\n  const { toggleSidebar, open, openMobile, isMobile } = useSidebar();\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sidebar-trigger\"\n      aria-label={msg(\"sidebar.toggleSidebar\", \"Toggle sidebar\")}\n      aria-expanded={isMobile ? openMobile : open}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      className={cn(\n        \"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4\",\n        className,\n      )}\n      {...props}\n    >\n      <PanelLeft aria-hidden=\"true\"  className=\"rtl:-scale-x-100\"/>\n    </button>\n  );\n}\n\ntype SidebarInsetProps = React.ComponentProps<\"main\">;\n/** The page area beside the sidebar. With `variant=\"inset\"` it becomes a rounded card. */\nfunction SidebarInset({ className, ...props }: SidebarInsetProps) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        \"relative flex min-h-svh min-w-0 flex-1 flex-col bg-background\",\n        \"peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:min-h-[calc(100svh-1rem)] peer-data-[variant=inset]:rounded-xl peer-data-[variant=inset]:border peer-data-[variant=inset]:shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\ntype SidebarSectionProps = React.ComponentProps<\"div\">;\nfunction SidebarHeader({ className, ...props }: SidebarSectionProps) {\n  return <div data-slot=\"sidebar-header\" className={cn(\"flex flex-col gap-2 p-2\", className)} {...props} />;\n}\nfunction SidebarFooter({ className, ...props }: SidebarSectionProps) {\n  return <div data-slot=\"sidebar-footer\" className={cn(\"flex flex-col gap-2 p-2\", className)} {...props} />;\n}\nfunction SidebarContent({ className, ...props }: SidebarSectionProps) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\nfunction SidebarSeparator({ className, ...props }: React.ComponentProps<\"hr\">) {\n  return <hr data-slot=\"sidebar-separator\" className={cn(\"mx-2 h-px w-auto border-0 bg-border\", className)} {...props} />;\n}\nfunction SidebarGroup({ className, ...props }: SidebarSectionProps) {\n  return <div data-slot=\"sidebar-group\" className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)} {...props} />;\n}\nfunction SidebarGroupLabel({ className, ...props }: SidebarSectionProps) {\n  return (\n    <div\n      data-slot=\"sidebar-group-label\"\n      className={cn(\n        \"flex h-8 shrink-0 items-center px-2 text-xs font-medium text-muted-foreground transition-[margin,opacity] duration-200 ease-linear motion-reduce:transition-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\nfunction SidebarGroupContent({ className, ...props }: SidebarSectionProps) {\n  return <div data-slot=\"sidebar-group-content\" className={cn(\"w-full text-sm\", className)} {...props} />;\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return <ul data-slot=\"sidebar-menu\" className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)} {...props} />;\n}\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return <li data-slot=\"sidebar-menu-item\" className={cn(\"group/menu-item relative\", className)} {...props} />;\n}\n\nconst menuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg px-2 text-start text-sm outline-none transition-[width,height,padding,background-color] hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 active:bg-accent disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-accent data-[active=true]:font-medium data-[active=true]:text-accent-foreground group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&>span:not(:first-child)]:sr-only group-data-[collapsible=icon]:[&>svg:not(:first-child)]:hidden [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: { size: { default: \"h-9\", sm: \"h-8 text-[13px]\", lg: \"h-12 group-data-[collapsible=icon]:p-0!\" } },\n    defaultVariants: { size: \"default\" },\n  },\n);\n\ntype SidebarMenuButtonProps = Omit<React.ComponentProps<\"button\">, \"type\"> & {\n  /** Link target. When set the button renders as an `<a>`; add `aria-current=\"page\"` via `isActive`. */\n  href?: string;\n  /** Render the child (a router link) with button styles and behavior. */\n  asChild?: boolean;\n  /** Highlight as the current page (sets `aria-current=\"page\"` on links). */\n  isActive?: boolean;\n  /** Tooltip text shown only while the sidebar is collapsed to icons. Defaults to the button's `aria-label`. */\n  tooltip?: string;\n  size?: \"default\" | \"sm\" | \"lg\";\n};\nfunction SidebarMenuButton({\n  asChild = false,\n  href,\n  isActive = false,\n  tooltip,\n  size = \"default\",\n  className,\n  ...props\n}: SidebarMenuButtonProps) {\n  const { isMobile, state } = useSidebar();\n  const dir = useDirection();\n  const Comp = asChild ? Slot.Root : href ? \"a\" : \"button\";\n  const button = (\n    <Comp\n      data-slot=\"sidebar-menu-button\"\n      data-active={isActive}\n      data-size={size}\n      aria-current={isActive && (href || asChild) ? \"page\" : undefined}\n      {...(href ? { href } : {})}\n      {...(!asChild && !href ? { type: \"button\" } : {})}\n      className={cn(menuButtonVariants({ size }), className)}\n      {...(props as object)}\n    />\n  );\n  if (!tooltip) return button;\n  // createElement keeps `asChild` out of JSX, which the shadcn CLI would rewrite for Base UI projects and break this Radix-based tooltip.\n  return (\n    <Tooltip>\n      {React.createElement(TooltipTrigger, { asChild: true }, button)}\n      <TooltipContent side={dir === \"rtl\" ? \"left\" : \"right\"} align=\"center\" hidden={state !== \"collapsed\" || isMobile}>\n        {tooltip}\n      </TooltipContent>\n    </Tooltip>\n  );\n}\n\nfunction SidebarMenuBadge({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      className={cn(\n        \"pointer-events-none absolute top-1/2 end-2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-md bg-muted px-1.5 text-xs font-medium text-muted-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      className={cn(\"mx-3.5 flex min-w-0 flex-col gap-0.5 border-s py-0.5 ps-2.5 group-data-[collapsible=icon]:hidden\", className)}\n      {...props}\n    />\n  );\n}\nfunction SidebarMenuSubItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return <li data-slot=\"sidebar-menu-sub-item\" className={cn(\"relative\", className)} {...props} />;\n}\ntype SidebarMenuSubButtonProps = React.ComponentProps<\"a\"> & { isActive?: boolean };\nfunction SidebarMenuSubButton({ isActive = false, className, ...props }: SidebarMenuSubButtonProps) {\n  return (\n    <a\n      data-slot=\"sidebar-menu-sub-button\"\n      data-active={isActive}\n      aria-current={isActive ? \"page\" : undefined}\n      className={cn(\n        \"flex h-8 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-[13px] text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active=true]:bg-accent data-[active=true]:font-medium data-[active=true]:text-accent-foreground [&>span:last-child]:truncate\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarProvider,\n  SidebarTrigger,\n  SidebarInset,\n  SidebarHeader,\n  SidebarFooter,\n  SidebarContent,\n  SidebarSeparator,\n  SidebarGroup,\n  SidebarGroupLabel,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuItem,\n  SidebarMenuButton,\n  SidebarMenuBadge,\n  SidebarMenuSub,\n  SidebarMenuSubItem,\n  SidebarMenuSubButton,\n  useSidebar,\n  type SidebarProps,\n  type SidebarProviderProps,\n  type SidebarTriggerProps,\n  type SidebarMenuButtonProps,\n  type SidebarMenuSubButtonProps,\n};\n",
      "type": "registry:ui",
      "target": "@components/ballmac/sidebar.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "navigation",
      "layout",
      "app shell",
      "collapsible"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "SidebarProvider wraps Sidebar and SidebarInset. Menu entries use SidebarMenuButton with href and isActive. SidebarTrigger toggles it.",
      "whenToUse": [
        "Application navigation with many sections",
        "Dashboards and admin tools"
      ],
      "whenNotToUse": [
        "Marketing site headers; use navigation-menu",
        "Two or three links; use tabs"
      ],
      "composesWith": [
        "sheet",
        "tooltip",
        "button",
        "avatar"
      ],
      "a11y": [
        {
          "keys": "Cmd/Ctrl+B",
          "action": "Toggles the sidebar"
        },
        {
          "keys": "Tab",
          "action": "Moves through links; the current page has aria-current"
        },
        {
          "keys": "Mobile",
          "action": "Opens as a named dialog sheet; Escape closes"
        }
      ],
      "customization": [
        "side, variant, collapsible",
        "defaultOpen or controlled open",
        "SidebarMenuBadge and sub-menus",
        "width via --sidebar-width"
      ]
    },
    "source": {
      "name": "shadcn/ui Sidebar",
      "url": "https://github.com/shadcn-ui/ui",
      "license": "MIT",
      "copyright": "Copyright (c) 2023 shadcn",
      "modified": true
    },
    "examples": [
      "sidebar-demo",
      "sidebar-states"
    ],
    "url": "https://ui.ballmac.com/components/sidebar"
  },
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}