{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dock-gallery",
  "type": "registry:ui",
  "title": "Dock Gallery",
  "description": "A dock-style magnifying gallery: the card nearest the pointer grows and neighbours step down.",
  "files": [
    {
      "path": "src/registry/ui/dock-gallery.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useRef } from \"react\";\nimport Image from \"next/image\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DockGalleryItem = {\n  title: string;\n  image: { src: string; alt?: string };\n  href?: string;\n  /** Small badge in the card's top-right corner. */\n  icon?: React.ReactNode;\n};\n\nexport type DockGalleryProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  items: DockGalleryItem[];\n  /** Pinned above the row, e.g. a brand, intro copy and nav. */\n  header?: React.ReactNode;\n  /** Card width (px) far from the focus. */\n  minWidth?: number;\n  /** Card width (px) at the focus. */\n  maxWidth?: number;\n  /** How many cards either side of the focus are magnified. */\n  spread?: number;\n  /** Card width ÷ height. */\n  aspectRatio?: number;\n};\n\nconst SIZE_SPRING = { stiffness: 320, damping: 32, mass: 0.6 };\nconst FOCUS_SPRING = { type: \"spring\", stiffness: 220, damping: 30 } as const;\n\nexport function DockGallery({\n  items,\n  header,\n  minWidth = 96,\n  maxWidth = 200,\n  spread = 3,\n  aspectRatio = 3 / 4,\n  className,\n  ...props\n}: DockGalleryProps) {\n  const reduceMotion = useReducedMotion();\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const restIndex = (items.length - 1) / 2;\n  // The magnified spot as a fractional card index: 3.5 sits between the 4th and 5th card.\n  // It starts in the middle, so even the server render shows the centred pyramid.\n  const focus = useMotionValue(restIndex);\n\n  const moveFocus = (target: number) => {\n    if (reduceMotion) focus.set(target);\n    else animate(focus, target, FOCUS_SPRING);\n  };\n\n  // Pointer x → fractional index, read from the cards' current boxes (one read per move).\n  const indexAt = (x: number) => {\n    const boxes = cardRefs.current.map((card) => card?.getBoundingClientRect());\n    for (let i = 0; i < boxes.length; i++) {\n      const box = boxes[i];\n      if (!box) continue;\n      // Card i spans i - 0.5 … i + 0.5; the gap before it sits exactly at i - 0.5.\n      if (x < box.left) return i - 0.5;\n      if (x <= box.right) return i - 0.5 + (x - box.left) / box.width;\n    }\n    return boxes.length - 0.5;\n  };\n\n  return (\n    <section\n      data-slot=\"dock-gallery\"\n      className={cn(\"flex w-full flex-col overflow-hidden rounded-xl bg-[#F3F0EE] text-neutral-900\", className)}\n      {...props}\n    >\n      {header && <div className=\"px-6 pt-5 sm:px-8\">{header}</div>}\n\n      <div\n        onPointerMove={(event) => {\n          if (event.pointerType === \"touch\") return;\n          // Follows the pointer directly; each card's size spring smooths the motion.\n          focus.stop();\n          focus.set(Math.min(Math.max(indexAt(event.clientX), 0), items.length - 1));\n        }}\n        onPointerLeave={() => moveFocus(restIndex)}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget)) moveFocus(restIndex);\n        }}\n        className=\"mt-auto flex items-end justify-center gap-3 overflow-hidden px-4 pt-10 pb-3 [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]\"\n      >\n        {items.map((item, index) => (\n          <DockCard\n            key={`${item.title}-${index}`}\n            ref={(card) => {\n              cardRefs.current[index] = card;\n            }}\n            item={item}\n            index={index}\n            focus={focus}\n            minWidth={minWidth}\n            maxWidth={maxWidth}\n            spread={spread}\n            aspectRatio={aspectRatio}\n            reduceMotion={!!reduceMotion}\n            onFocusCard={() => moveFocus(index)}\n          />\n        ))}\n      </div>\n    </section>\n  );\n}\n\nfunction DockCard({\n  ref,\n  item,\n  index,\n  focus,\n  minWidth,\n  maxWidth,\n  spread,\n  aspectRatio,\n  reduceMotion,\n  onFocusCard,\n}: {\n  ref: React.Ref<HTMLDivElement>;\n  item: DockGalleryItem;\n  index: number;\n  focus: MotionValue<number>;\n  minWidth: number;\n  maxWidth: number;\n  spread: number;\n  aspectRatio: number;\n  reduceMotion: boolean;\n  onFocusCard: () => void;\n}) {\n  // Cosine falloff: full size at the focus, easing to minWidth `spread` cards away.\n  // Big, smaller, smaller… on both sides, with no hard steps between neighbours.\n  const targetWidth = useTransform(focus, (value) => {\n    const t = Math.max(0, 1 - Math.abs(index - value) / spread);\n    return minWidth + (maxWidth - minWidth) * ((1 - Math.cos(t * Math.PI)) / 2);\n  });\n  const springWidth = useSpring(targetWidth, SIZE_SPRING);\n  const width = reduceMotion ? targetWidth : springWidth;\n  const height = useTransform(width, (value) => value / aspectRatio);\n  // Labels and badges quieten with distance, so the focused card leads.\n  const emphasis = useTransform(width, [minWidth, maxWidth], [0.55, 1]);\n\n  const card = (\n    <motion.div\n      ref={ref}\n      style={{ width, height }}\n      className=\"relative overflow-hidden rounded-md bg-neutral-300 shadow-sm\"\n    >\n      <Image\n        src={item.image.src}\n        alt={item.image.alt ?? \"\"}\n        fill\n        sizes={`${maxWidth}px`}\n        draggable={false}\n        className=\"object-cover\"\n      />\n      {item.icon && (\n        <motion.span\n          style={{ opacity: emphasis }}\n          className=\"absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-sm bg-white/90 text-neutral-900 shadow-sm [&>svg]:size-4\"\n        >\n          {item.icon}\n        </motion.span>\n      )}\n    </motion.div>\n  );\n\n  return (\n    <div className=\"flex shrink-0 flex-col items-start gap-2.5\">\n      <motion.p style={{ opacity: emphasis }} className=\"flex items-center gap-1.5 text-xs whitespace-nowrap\">\n        <span aria-hidden className=\"size-2 rounded-full border border-current\" />\n        {item.title}\n      </motion.p>\n      {item.href ? (\n        <a\n          href={item.href}\n          onFocus={onFocusCard}\n          className=\"rounded-md outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#F3F0EE]\"\n        >\n          <span className=\"sr-only\">{item.title}</span>\n          {card}\n        </a>\n      ) : (\n        card\n      )}\n    </div>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "motion"
  ]
}
