{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dock",
  "title": "Dock",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A macOS-style dock whose icons grow with spring physics as the pointer approaches and push their neighbors apart. Labels, running dots, a launch bounce and arrow-key navigation included.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/dock.tsx",
      "content": "// Ballmac UI: Dock. https://ui.ballmac.com/components/dock\n\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\n\ntype DockOrientation = \"horizontal\" | \"vertical\"\n\ntype DockContextValue = {\n  pointer: MotionValue<number>\n  size: number\n  magnification: number\n  distance: number\n  orientation: DockOrientation\n  reduceMotion: boolean\n}\n\nconst DockContext = React.createContext<DockContextValue | null>(null)\n\nfunction useDock() {\n  const context = React.useContext(DockContext)\n  if (!context) throw new Error(\"DockItem and DockSeparator must be used inside <Dock>.\")\n  return context\n}\n\nconst ITEM_SELECTOR = '[data-slot=\"dock-item\"]:not([disabled])'\n\nfunction visibleItems(root: HTMLElement | null) {\n  if (!root) return []\n  const shown = (el: Element | null) => !el || getComputedStyle(el).display !== \"none\"\n  return Array.from(root.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter((el) => shown(el) && shown(el.parentElement))\n}\n\ntype DockProps = React.ComponentProps<\"div\"> & {\n  /** Resting size of each item in px. */\n  size?: number\n  /** Size in px of the item right under the pointer (or keyboard focus). */\n  magnification?: number\n  /** How far in px from the pointer the magnification reaches. */\n  distance?: number\n  /** Lay the dock out in a row (bottom of the screen) or a column (side of the screen). */\n  orientation?: DockOrientation\n}\n\n/**\n * A macOS-style dock. Items grow as the pointer approaches and push their neighbors apart.\n * It is a `toolbar`: Tab enters it once, arrow keys move between items, Home and End jump to the ends.\n */\nfunction Dock({\n  size = 48,\n  magnification = 76,\n  distance = 150,\n  orientation = \"horizontal\",\n  className,\n  style,\n  children,\n  onPointerMove,\n  onPointerLeave,\n  onKeyDown,\n  onFocus,\n  ...props\n}: DockProps) {\n  const dir = useDirection()\n  const ref = React.useRef<HTMLDivElement>(null)\n  const pointer = useMotionValue(Number.POSITIVE_INFINITY)\n  const reduceMotion = useReducedMotion() ?? false\n  const vertical = orientation === \"vertical\"\n  const current = React.useRef(0)\n\n  // Roving tab stop: exactly one item is reachable with Tab.\n  React.useEffect(() => {\n    const items = visibleItems(ref.current)\n    if (current.current >= items.length) current.current = 0\n    items.forEach((el, i) => (el.tabIndex = i === current.current ? 0 : -1))\n  })\n\n  const context = React.useMemo<DockContextValue>(\n    () => ({ pointer, size, magnification: reduceMotion ? size : magnification, distance, orientation, reduceMotion }),\n    [pointer, size, magnification, distance, orientation, reduceMotion]\n  )\n\n  return (\n    <DockContext.Provider value={context}>\n      <div\n        ref={ref}\n        role=\"toolbar\"\n        aria-orientation={orientation}\n        data-slot=\"dock\"\n        data-orientation={orientation}\n        className={cn(\n          \"relative isolate flex w-max max-w-full gap-1 rounded-[22px] p-1.5\",\n          vertical ? \"flex-col items-center justify-center\" : \"items-end justify-center\",\n          // Vibrancy: translucent glass with a hairline edge and a lit top edge.\n          \"border border-white/40 bg-background/45 shadow-[inset_0_1px_0_0_rgb(255_255_255/0.45),0_0_0_0.5px_rgb(0_0_0/0.14),0_16px_40px_-12px_rgb(0_0_0/0.35)] backdrop-blur-2xl backdrop-saturate-150\",\n          \"dark:border-white/12 dark:bg-background/40 dark:shadow-[inset_0_1px_0_0_rgb(255_255_255/0.1),0_0_0_0.5px_rgb(0_0_0/0.6),0_16px_40px_-12px_rgb(0_0_0/0.6)]\",\n          className\n        )}\n        style={{ [vertical ? \"width\" : \"height\"]: size + 14, ...style }}\n        onPointerMove={(event) => {\n          if (event.pointerType !== \"touch\") pointer.set(vertical ? event.clientY : event.clientX)\n          onPointerMove?.(event)\n        }}\n        onPointerLeave={(event) => {\n          pointer.set(Number.POSITIVE_INFINITY)\n          onPointerLeave?.(event)\n        }}\n        onFocus={(event) => {\n          const items = visibleItems(ref.current)\n          const index = items.indexOf(event.target as HTMLElement)\n          if (index !== -1) {\n            current.current = index\n            items.forEach((el, i) => (el.tabIndex = i === index ? 0 : -1))\n          }\n          onFocus?.(event)\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (event.defaultPrevented) return\n          const items = visibleItems(ref.current)\n          const index = items.indexOf(document.activeElement as HTMLElement)\n          if (index === -1) return\n          const prev = vertical ? \"ArrowUp\" : dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\"\n          const next = vertical ? \"ArrowDown\" : dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\"\n          let target = -1\n          if (event.key === next) target = (index + 1) % items.length\n          else if (event.key === prev) target = (index - 1 + items.length) % items.length\n          else if (event.key === \"Home\") target = 0\n          else if (event.key === \"End\") target = items.length - 1\n          if (target === -1) return\n          event.preventDefault()\n          items[target].focus()\n        }}\n        {...props}\n      >\n        {children}\n      </div>\n    </DockContext.Provider>\n  )\n}\n\ntype DockItemProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onAnimationStart\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\"> & {\n  /** Name shown in the label above the item; also the accessible name. */\n  label: string\n  /** The icon, drawn centered on the tile at about half its size. */\n  icon?: React.ReactNode\n  /** Custom tile content instead of `icon`. */\n  children?: React.ReactNode\n  /** Shows the running indicator dot under the item. */\n  active?: boolean\n  /** Text added to the accessible name when `active`, so the dot isn't the only signal. */\n  activeLabel?: string\n  /** Bounce the item when it is clicked, like an app launching. */\n  bounce?: boolean\n  /** Classes for the item's outer wrapper, e.g. `max-sm:hidden` to drop an item on small screens. `className` styles the tile. */\n  containerClassName?: string\n}\n\nfunction DockItem({\n  label,\n  icon,\n  children,\n  active = false,\n  activeLabel = \"running\",\n  bounce = true,\n  containerClassName,\n  className,\n  style,\n  onClick,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  type = \"button\",\n  ...props\n}: DockItemProps) {\n  const { pointer, size, magnification, distance, orientation, reduceMotion } = useDock()\n  const ref = React.useRef<HTMLButtonElement>(null)\n  const vertical = orientation === \"vertical\"\n  const [showLabel, setShowLabel] = React.useState(false)\n  const focusedRef = React.useRef(false)\n\n  // Distance from the pointer to this item's center, measured live so neighbors shift smoothly.\n  const offset = useTransform(pointer, (value) => {\n    const rect = ref.current?.getBoundingClientRect()\n    if (!rect || !Number.isFinite(value)) return Number.POSITIVE_INFINITY\n    return value - (vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2)\n  })\n  const target = useTransform(offset, [-distance, 0, distance], [size, magnification, size], { clamp: true })\n  const itemSize = useSpring(target, { mass: 0.1, stiffness: 170, damping: 14 })\n  const lift = useMotionValue(0)\n\n  const centerPointerOnSelf = () => {\n    const rect = ref.current?.getBoundingClientRect()\n    if (rect) pointer.set(vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2)\n  }\n\n  return (\n    <div\n      data-slot=\"dock-item-wrapper\"\n      className={cn(\"relative flex shrink-0\", vertical ? \"flex-row items-center\" : \"flex-col items-center\", containerClassName)}\n    >\n      <AnimatePresence>\n        {showLabel ? (\n          <motion.span\n            data-slot=\"dock-item-label\"\n            initial={{ opacity: 0, [vertical ? \"x\" : \"y\"]: 4 }}\n            animate={{ opacity: 1, x: 0, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}\n            className={cn(\n              \"pointer-events-none absolute z-10 whitespace-nowrap rounded-md border border-foreground/10 bg-background/80 px-2.5 py-1 text-xs font-medium text-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.3)] backdrop-blur-xl\",\n              vertical ? \"start-full ms-3\" : \"bottom-full mb-2.5\"\n            )}\n            aria-hidden=\"true\"\n          >\n            {label}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n      <motion.button\n        ref={ref}\n        type={type}\n        data-slot=\"dock-item\"\n        data-active={active ? \"\" : undefined}\n        aria-label={active ? `${label}, ${activeLabel}` : label}\n        style={{ ...style, width: itemSize, height: itemSize, [vertical ? \"x\" : \"y\"]: lift }}\n        className={cn(\n          \"group/dock-item relative flex shrink-0 items-center justify-center overflow-hidden rounded-[23%] bg-card text-foreground outline-none\",\n          \"shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.22),0_1px_1px_rgb(0_0_0/0.12),0_4px_10px_-4px_rgb(0_0_0/0.35)]\",\n          \"focus-visible:ring-[3px] focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50\",\n          \"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[52%] [&_svg]:shrink-0\",\n          // Reduced motion: no magnification, a simple highlight instead (CSS, so server and client markup match).\n          \"motion-reduce:transition-[filter] motion-reduce:duration-150 motion-reduce:hover:brightness-110 motion-reduce:focus-visible:brightness-110\",\n          className\n        )}\n        onClick={(event) => {\n          if (bounce && !reduceMotion) {\n            const up = vertical ? 14 : -18\n            animate(lift, [0, up, 0, up * 0.4, 0], { duration: 0.7, times: [0, 0.3, 0.6, 0.8, 1], ease: \"easeInOut\" })\n          }\n          onClick?.(event)\n        }}\n        onPointerEnter={(event) => {\n          setShowLabel(true)\n          onPointerEnter?.(event)\n        }}\n        onPointerLeave={(event) => {\n          if (!focusedRef.current) setShowLabel(false)\n          onPointerLeave?.(event)\n        }}\n        onFocus={(event) => {\n          let visible = true\n          try {\n            visible = event.currentTarget.matches(\":focus-visible\")\n          } catch {}\n          if (visible) {\n            focusedRef.current = true\n            setShowLabel(true)\n            centerPointerOnSelf()\n          }\n          onFocus?.(event)\n        }}\n        onBlur={(event) => {\n          if (focusedRef.current) {\n            focusedRef.current = false\n            setShowLabel(false)\n            pointer.set(Number.POSITIVE_INFINITY)\n          }\n          onBlur?.(event)\n        }}\n        {...props}\n      >\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"dock-item-sheen\"\n          className=\"pointer-events-none absolute inset-0 rounded-[inherit] bg-linear-to-b from-white/25 via-white/0 to-black/10\"\n        />\n        {icon ?? children}\n      </motion.button>\n      {active ? (\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"dock-item-indicator\"\n          className={cn(\n            \"pointer-events-none absolute size-1 rounded-full bg-foreground/70\",\n            vertical ? \"-start-[4.5px] top-1/2 -mt-0.5\" : \"-bottom-[5px] left-1/2 -ms-0.5\"\n          )}\n        />\n      ) : null}\n    </div>\n  )\n}\n\ntype DockSeparatorProps = React.ComponentProps<\"div\">\n\n/** A hairline divider, like the one between apps and folders in the macOS Dock. */\nfunction DockSeparator({ className, ...props }: DockSeparatorProps) {\n  const { orientation } = useDock()\n  const vertical = orientation === \"vertical\"\n  return (\n    <div\n      role=\"separator\"\n      aria-orientation={vertical ? \"horizontal\" : \"vertical\"}\n      data-slot=\"dock-separator\"\n      className={cn(\n        \"shrink-0 self-stretch bg-foreground/20\",\n        vertical ? \"mx-1.5 my-0.5 h-px\" : \"mx-0.5 my-1.5 w-px\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Dock, DockItem, DockSeparator, type DockItemProps, type DockOrientation, type DockProps, type DockSeparatorProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/dock.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "dock",
      "macos",
      "magnification",
      "toolbar",
      "navigation",
      "motion",
      "app launcher"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "<Dock> is a role=toolbar row (or column with orientation=\"vertical\") of <DockItem label icon /> buttons with cursor-proximity magnification; <DockSeparator /> draws the divider. Style each tile through DockItem's className (gradients, colors); icons are sized to about half the tile.",
      "whenToUse": [
        "An app-launcher or quick-links bar on a Mac-flavored landing page or product hero",
        "A floating navigation bar for a portfolio or dashboard with a handful of destinations",
        "Showing off the apps or integrations a product ships with"
      ],
      "whenNotToUse": [
        "Primary navigation with many text links (use a header or sidebar)",
        "More than about 12 items, or anywhere it would need to scroll horizontally"
      ],
      "composesWith": [
        "mac-window",
        "menu-bar",
        "dynamic-island"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves focus into the dock (one tab stop) and out again"
        },
        {
          "keys": "ArrowLeft / ArrowRight (ArrowUp / ArrowDown when vertical)",
          "action": "Moves between items; the focused item magnifies and shows its label"
        },
        {
          "keys": "Home / End",
          "action": "Jumps to the first or last item"
        },
        {
          "keys": "Enter / Space",
          "action": "Activates the focused item"
        }
      ],
      "customization": [
        "size: resting item size in px (default 48); magnification: size under the pointer (default 76); distance: reach in px (default 150)",
        "orientation: horizontal | vertical",
        "DockItem: label, icon, active (running dot, also announced), bounce (launch bounce on click), className for the tile, containerClassName for the wrapper (e.g. max-sm:hidden)",
        "Under reduced motion the dock does not magnify or bounce; items brighten on hover and focus instead"
      ]
    },
    "examples": [
      "dock-demo",
      "dock-vertical"
    ],
    "url": "https://ui.ballmac.com/components/dock"
  },
  "categories": [
    "macos"
  ],
  "type": "registry:ui"
}