{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A Dock component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type FocusEvent as ReactFocusEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_BASE_SIZE = 48;\nconst DEFAULT_GAP = 10;\nconst DEFAULT_MAGNIFICATION = 1.55;\n/** ~3x the item pitch, so growth tapers off across 2-3 neighbours. */\nconst DEFAULT_DISTANCE = 174;\nconst CHROME_PADDING = 8;\nconst HALF = 0.5;\nconst QUARTER = 0.25;\nconst POINTER_SOLVE_STEPS = 3;\nconst TRACK_SPRING = { bounce: 0, duration: 0.18 };\nconst INFLUENCE_SPRING = { bounce: 0, duration: 0.28 };\nconst TOOLTIP_DURATION = 0.14;\nconst TOOLTIP_HIDDEN_SCALE = 0.94;\nconst BOUNCE_DURATION = 0.6;\nconst BOUNCE_PEAK = -0.4;\nconst BOUNCE_ECHO = -0.13;\nconst BOUNCE_TIMES = [0, 0.3, 0.58, 0.8, 1];\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst EASE_IN: [number, number, number, number] = [0.55, 0, 1, 0.45];\n\nexport type DockOrientation = \"horizontal\" | \"vertical\";\n\nexport interface DockItem {\n  /** Draws the macOS \"app is running\" dot beside the tile. */\n  active?: boolean;\n  href?: string;\n  icon: ReactNode;\n  id: string;\n  label: string;\n  onSelect?: () => void;\n}\n\nexport interface DockProps {\n  /** Resting edge length of every tile, in px. */\n  baseSize?: number;\n  className?: string;\n  /** Radius of cursor influence in px. Around 3x the item pitch reads best. */\n  distance?: number;\n  /** Resting space between tiles, in px. */\n  gap?: number;\n  items: DockItem[];\n  /** Scale of the tile directly under the cursor. */\n  magnification?: number;\n  orientation?: DockOrientation;\n  /** Keep every label visible instead of only on hover/focus. */\n  showLabels?: boolean;\n}\n\ntype DockField = {\n  baseSize: number;\n  count: number;\n  distance: number;\n  gap: number;\n  influence: number;\n  magnification: number;\n  pointer: number;\n};\n\n/**\n * Raised-cosine falloff: flat near the cursor, flat at the edge of the\n * influence radius. A linear ramp is what makes a dock look like only the\n * hovered icon reacts — this curve gives the neighbours real growth and lets\n * the wave die out smoothly.\n */\nconst falloffAt = (delta: number, distance: number) => {\n  const spread = Math.abs(delta);\n  if (spread >= distance) {\n    return 0;\n  }\n  return (Math.cos((Math.PI * spread) / distance) + 1) * HALF;\n};\n\n/**\n * Signed integral of `falloffAt` from 0 to `delta`. Laying the row out by\n * integrating the local stretch is what makes every tile push its neighbours\n * by exactly the space its own growth needs — no overlap, no guesswork.\n */\nconst falloffIntegral = (delta: number, distance: number) => {\n  const sign = delta < 0 ? -1 : 1;\n  const spread = Math.min(Math.abs(delta), distance);\n  return (\n    sign *\n    ((distance / (2 * Math.PI)) * Math.sin((Math.PI * spread) / distance) +\n      spread * HALF)\n  );\n};\n\nconst restingCenter = (index: number, field: DockField) =>\n  index * (field.baseSize + field.gap) + field.baseSize * HALF;\n\nconst gainOf = (field: DockField) =>\n  field.influence * (field.magnification - 1);\n\nconst scaleAt = (index: number, field: DockField) =>\n  1 +\n  gainOf(field) *\n    falloffAt(restingCenter(index, field) - field.pointer, field.distance);\n\n/** Shift that keeps the magnified row centred on its resting centre. */\nconst centreShift = (field: DockField) => {\n  const first = restingCenter(0, field) - field.pointer;\n  const last = restingCenter(field.count - 1, field) - field.pointer;\n  return (\n    (falloffIntegral(first, field.distance) +\n      falloffIntegral(last, field.distance)) *\n      HALF +\n    field.baseSize *\n      (falloffAt(last, field.distance) - falloffAt(first, field.distance)) *\n      QUARTER\n  );\n};\n\nconst centreAt = (index: number, field: DockField) => {\n  const resting = restingCenter(index, field);\n  return (\n    resting +\n    gainOf(field) *\n      (falloffIntegral(resting - field.pointer, field.distance) -\n        centreShift(field))\n  );\n};\n\nconst rowLength = (field: DockField) => {\n  const last = Math.max(0, field.count - 1);\n  const start = centreAt(0, field) - field.baseSize * scaleAt(0, field) * HALF;\n  const end =\n    centreAt(last, field) + field.baseSize * scaleAt(last, field) * HALF;\n  return end - start;\n};\n\n/**\n * The cursor arrives in rendered pixels, but the layout is defined in resting\n * coordinates. `rendered = pointer - gain * centreShift(pointer)` is smooth and\n * near-identity, so a few fixed-point steps invert it well enough — and that\n * keeps the magnified peak glued to the cursor, instead of drifting the way a\n * live `getBoundingClientRect` feedback loop does.\n */\nconst solvePointer = (rendered: number, field: DockField) => {\n  const gain = gainOf(field);\n  const probe: DockField = { ...field, pointer: rendered };\n  for (let step = 0; step < POINTER_SOLVE_STEPS; step += 1) {\n    probe.pointer = rendered + gain * centreShift(probe);\n  }\n  return probe.pointer;\n};\n\nconst useHoverCapable = () => {\n  const [isHoverCapable, setIsHoverCapable] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverCapable(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverCapable(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  return isHoverCapable;\n};\n\ntype DockIconElement = HTMLAnchorElement | HTMLButtonElement;\n\ntype DockIconProps = {\n  baseSize: number;\n  fieldRef: { current: DockField };\n  index: number;\n  influence: MotionValue<number>;\n  isTooltipInstant: boolean;\n  item: DockItem;\n  onFocusItem: () => void;\n  onKeyDown: (event: ReactKeyboardEvent<DockIconElement>) => void;\n  onPointerEnter: () => void;\n  orientation: DockOrientation;\n  pointer: MotionValue<number>;\n  registerRef: (element: DockIconElement | null) => void;\n  shouldReduceMotion: boolean;\n  showTooltip: boolean;\n  tabIndex: number;\n};\n\nconst DockIcon = ({\n  baseSize,\n  fieldRef,\n  index,\n  influence,\n  isTooltipInstant,\n  item,\n  onFocusItem,\n  onKeyDown,\n  onPointerEnter,\n  orientation,\n  pointer,\n  registerRef,\n  shouldReduceMotion,\n  showTooltip,\n  tabIndex,\n}: DockIconProps) => {\n  const isHorizontal = orientation === \"horizontal\";\n  const bounce = useMotionValue(0);\n\n  const offset = useTransform([pointer, influence], (latest: number[]) => {\n    const field: DockField = {\n      ...fieldRef.current,\n      influence: latest[1],\n      pointer: latest[0],\n    };\n    return centreAt(index, field) - field.baseSize * HALF;\n  });\n\n  const scale = useTransform([pointer, influence], (latest: number[]) => {\n    const field: DockField = {\n      ...fieldRef.current,\n      influence: latest[1],\n      pointer: latest[0],\n    };\n    return scaleAt(index, field);\n  });\n\n  /** Ride above the magnified tile instead of overlapping it. */\n  const tooltipShift = useTransform(scale, (value) => -(value - 1) * baseSize);\n\n  const handleSelect = () => {\n    if (!shouldReduceMotion) {\n      bounce.jump(0);\n      animate(\n        bounce,\n        [0, BOUNCE_PEAK * baseSize, 0, BOUNCE_ECHO * baseSize, 0],\n        {\n          duration: BOUNCE_DURATION,\n          ease: [EASE_OUT, EASE_IN, EASE_OUT, EASE_IN],\n          times: BOUNCE_TIMES,\n        }\n      );\n    }\n    item.onSelect?.();\n  };\n\n  const sharedProps = {\n    \"aria-label\": item.active ? `${item.label} (running)` : item.label,\n    className:\n      \"flex size-full items-center justify-center rounded-[26%] bg-foreground/[0.07] text-foreground outline-none ring-1 ring-foreground/10 ring-inset transition-transform duration-150 ease-out focus-visible:ring-2 focus-visible:ring-brand active:scale-[0.94] motion-reduce:transition-none motion-reduce:active:scale-100\",\n    onClick: handleSelect,\n    onFocus: onFocusItem,\n    onKeyDown,\n    tabIndex,\n  };\n\n  return (\n    <motion.div\n      className={cn(\n        \"absolute\",\n        isHorizontal ? \"bottom-0 left-0\" : \"top-0 right-0\"\n      )}\n      onPointerEnter={onPointerEnter}\n      style={{\n        height: baseSize,\n        width: baseSize,\n        ...(isHorizontal ? { x: offset } : { y: offset }),\n      }}\n    >\n      <AnimatePresence>\n        {showTooltip ? (\n          <motion.span\n            animate={{ opacity: 1, scale: 1 }}\n            className={cn(\n              \"pointer-events-none absolute z-20 whitespace-nowrap rounded-md bg-foreground px-2 py-1 font-medium text-[11px] text-background shadow-sm\",\n              isHorizontal\n                ? \"bottom-full left-1/2 mb-2.5\"\n                : \"top-1/2 right-full mr-2.5\"\n            )}\n            exit={{ opacity: 0, scale: TOOLTIP_HIDDEN_SCALE }}\n            initial={{ opacity: 0, scale: TOOLTIP_HIDDEN_SCALE }}\n            key=\"tooltip\"\n            style={\n              isHorizontal\n                ? { originY: 1, x: \"-50%\", y: tooltipShift }\n                : { originX: 1, x: tooltipShift, y: \"-50%\" }\n            }\n            transition={{\n              duration:\n                shouldReduceMotion || isTooltipInstant ? 0 : TOOLTIP_DURATION,\n              ease: EASE_OUT,\n            }}\n          >\n            {item.label}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n\n      <motion.div\n        className=\"size-full\"\n        style={{\n          scale,\n          transformOrigin: isHorizontal ? \"bottom center\" : \"right center\",\n          ...(isHorizontal ? { y: bounce } : { x: bounce }),\n        }}\n      >\n        {item.href ? (\n          <a href={item.href} ref={registerRef} {...sharedProps}>\n            {item.icon}\n          </a>\n        ) : (\n          <button ref={registerRef} type=\"button\" {...sharedProps}>\n            {item.icon}\n          </button>\n        )}\n      </motion.div>\n\n      {item.active ? (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"absolute size-[3px] rounded-full bg-foreground/45\",\n            isHorizontal\n              ? \"-bottom-1.5 left-1/2 -translate-x-1/2\"\n              : \"top-1/2 -right-1.5 -translate-y-1/2\"\n          )}\n        />\n      ) : null}\n    </motion.div>\n  );\n};\n\nexport default function Dock({\n  baseSize = DEFAULT_BASE_SIZE,\n  className,\n  distance = DEFAULT_DISTANCE,\n  gap = DEFAULT_GAP,\n  items,\n  magnification = DEFAULT_MAGNIFICATION,\n  orientation = \"horizontal\",\n  showLabels = false,\n}: DockProps) {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const isHoverCapable = useHoverCapable();\n  const isHorizontal = orientation === \"horizontal\";\n  const isMagnetic = isHoverCapable && !shouldReduceMotion;\n\n  const count = items.length;\n  const restingLength = Math.max(baseSize, count * (baseSize + gap) - gap);\n\n  const fieldRef = useRef<DockField>({\n    baseSize,\n    count,\n    distance,\n    gap,\n    influence: 0,\n    magnification,\n    pointer: 0,\n  });\n  fieldRef.current = {\n    baseSize,\n    count,\n    distance,\n    gap,\n    influence: 0,\n    magnification: isMagnetic ? magnification : 1,\n    pointer: 0,\n  };\n\n  const frameRef = useRef<HTMLDivElement | null>(null);\n  const itemRefs = useRef<(DockIconElement | null)[]>([]);\n  const isTrackingRef = useRef(false);\n  const hasOpenTooltipRef = useRef(false);\n\n  const rawPointer = useMotionValue(0);\n  const rawInfluence = useMotionValue(0);\n  const smoothPointer = useSpring(rawPointer, TRACK_SPRING);\n  const influence = useSpring(rawInfluence, INFLUENCE_SPRING);\n\n  const fieldPointer = useTransform(\n    [smoothPointer, influence],\n    (latest: number[]) =>\n      solvePointer(latest[0], { ...fieldRef.current, influence: latest[1] })\n  );\n\n  /** The bar hugs the magnified row, so it widens with it and never jumps. */\n  const barLength = useTransform(\n    [fieldPointer, influence],\n    (latest: number[]) =>\n      rowLength({\n        ...fieldRef.current,\n        influence: latest[1],\n        pointer: latest[0],\n      }) +\n      CHROME_PADDING * 2\n  );\n\n  const [focusedIndex, setFocusedIndex] = useState(0);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [isTooltipInstant, setIsTooltipInstant] = useState(false);\n\n  const releasePointer = () => {\n    isTrackingRef.current = false;\n    rawInfluence.set(0);\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {\n    const frame = frameRef.current;\n    if (!(isMagnetic && frame)) {\n      return;\n    }\n    const rect = frame.getBoundingClientRect();\n    const value = isHorizontal\n      ? event.clientX - rect.left\n      : event.clientY - rect.top;\n\n    if (!isTrackingRef.current) {\n      rawPointer.jump(value);\n      smoothPointer.jump(value);\n      isTrackingRef.current = true;\n    }\n    rawPointer.set(value);\n    rawInfluence.set(1);\n  };\n\n  const handlePointerLeave = () => {\n    releasePointer();\n    setHoveredId(null);\n    setIsTooltipInstant(false);\n    hasOpenTooltipRef.current = false;\n  };\n\n  const handleItemPointerEnter = (id: string) => {\n    if (!isHoverCapable) {\n      return;\n    }\n    setIsTooltipInstant(hasOpenTooltipRef.current);\n    hasOpenTooltipRef.current = true;\n    setHoveredId(id);\n  };\n\n  const focusItem = (index: number) => {\n    if (count === 0) {\n      return;\n    }\n    const nextIndex = (index + count) % count;\n    setFocusedIndex(nextIndex);\n    itemRefs.current[nextIndex]?.focus();\n  };\n\n  const handleKeyDown = (event: ReactKeyboardEvent<DockIconElement>) => {\n    const forwardKey = isHorizontal ? \"ArrowRight\" : \"ArrowDown\";\n    const backwardKey = isHorizontal ? \"ArrowLeft\" : \"ArrowUp\";\n\n    if (event.key === forwardKey) {\n      event.preventDefault();\n      focusItem(focusedIndex + 1);\n    } else if (event.key === backwardKey) {\n      event.preventDefault();\n      focusItem(focusedIndex - 1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      focusItem(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      focusItem(count - 1);\n    }\n  };\n\n  const handleFocusItem = (index: number, id: string) => {\n    setFocusedIndex(index);\n    setHoveredId(id);\n    if (shouldReduceMotion) {\n      return;\n    }\n    const target = restingCenter(index, fieldRef.current);\n    rawPointer.jump(target);\n    smoothPointer.jump(target);\n    isTrackingRef.current = true;\n    rawInfluence.set(1);\n  };\n\n  const handleBlur = (event: ReactFocusEvent<HTMLElement>) => {\n    if (event.currentTarget.contains(event.relatedTarget)) {\n      return;\n    }\n    setHoveredId(null);\n    releasePointer();\n  };\n\n  const axisReserve = CHROME_PADDING + (magnification - 1) * distance * HALF;\n  const crossReserve = CHROME_PADDING + baseSize * (magnification - 1);\n\n  return (\n    <nav\n      aria-label=\"Application dock\"\n      className={cn(\"relative inline-flex\", className)}\n      onBlur={handleBlur}\n      onPointerLeave={handlePointerLeave}\n      onPointerMove={handlePointerMove}\n      style={\n        isHorizontal\n          ? {\n              paddingBottom: CHROME_PADDING,\n              paddingLeft: axisReserve,\n              paddingRight: axisReserve,\n              paddingTop: crossReserve,\n            }\n          : {\n              paddingBottom: axisReserve,\n              paddingLeft: crossReserve,\n              paddingRight: CHROME_PADDING,\n              paddingTop: axisReserve,\n            }\n      }\n    >\n      {/* Fixed resting-size frame — the stable coordinate space the cursor is\n          measured against, so no layout in here can ever jump. */}\n      <div\n        className=\"relative\"\n        ref={frameRef}\n        style={\n          isHorizontal\n            ? { height: baseSize, width: restingLength }\n            : { height: restingLength, width: baseSize }\n        }\n      >\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-1/2 left-1/2 rounded-[18px] border border-foreground/10 border-t-foreground/20 bg-background/65 shadow-black/10 shadow-lg backdrop-blur-xl\"\n          style={\n            isHorizontal\n              ? {\n                  height: baseSize + CHROME_PADDING * 2,\n                  width: barLength,\n                  x: \"-50%\",\n                  y: \"-50%\",\n                }\n              : {\n                  height: barLength,\n                  width: baseSize + CHROME_PADDING * 2,\n                  x: \"-50%\",\n                  y: \"-50%\",\n                }\n          }\n        />\n\n        <div\n          aria-orientation={orientation}\n          className=\"absolute inset-0\"\n          role=\"toolbar\"\n        >\n          {items.map((item, index) => (\n            <DockIcon\n              baseSize={baseSize}\n              fieldRef={fieldRef}\n              index={index}\n              influence={influence}\n              isTooltipInstant={isTooltipInstant}\n              item={item}\n              key={item.id}\n              onFocusItem={() => handleFocusItem(index, item.id)}\n              onKeyDown={handleKeyDown}\n              onPointerEnter={() => handleItemPointerEnter(item.id)}\n              orientation={orientation}\n              pointer={fieldPointer}\n              registerRef={(element) => {\n                itemRefs.current[index] = element;\n              }}\n              shouldReduceMotion={shouldReduceMotion}\n              showTooltip={showLabels || hoveredId === item.id}\n              tabIndex={index === focusedIndex ? 0 : -1}\n            />\n          ))}\n        </div>\n      </div>\n    </nav>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/dock/index.tsx","type":"registry:ui"}],"name":"dock","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Dock","type":"registry:ui"}