{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inertia-gallery",
  "title": "Inertia Gallery",
  "description": "A horizontal gallery you throw with the pointer — momentum carries it, it rubber-bands at the ends, and slides scale, blur, and fade with distance from center.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/inertia-gallery/inertia-gallery.tsx",
      "content": "\"use client\";\n\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type InertiaGalleryProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onChange\"\n> & {\n  /** Slides, in order. */\n  children?: React.ReactNode;\n  /** Slide width in px. */\n  itemWidth?: number;\n  /** Gap between slides in px. */\n  gap?: number;\n  /** Snap the nearest slide to center when the throw settles. */\n  snap?: boolean;\n  /** 0 disables the distance-from-center scale/blur/opacity falloff. */\n  falloff?: number;\n  /** Slide centered on mount. */\n  defaultIndex?: number;\n  /** Fired when a new slide reaches center. */\n  onChange?: (index: number) => void;\n};\n\n// SPRING.smooth — surfaces / settle.\nconst SETTLE_SPRING = {\n  type: \"spring\",\n  stiffness: 320,\n  damping: 32,\n  mass: 0.9,\n} as const;\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.max(lo, Math.min(hi, v));\n\ntype ItemProps = {\n  offset: number;\n  pitch: number;\n  x: MotionValue<number>;\n  falloff: number;\n  reduce: boolean;\n  width: number;\n  children: React.ReactNode;\n};\n\nconst InertiaItem: React.FC<ItemProps> = ({\n  offset,\n  pitch,\n  x,\n  falloff,\n  reduce,\n  width,\n  children,\n}) => {\n  // Normalised distance of this slide's center from the viewport center.\n  const nd = useTransform(x, (xv) =>\n    Math.min(Math.abs(offset + xv) / pitch, 2.4),\n  );\n  const scale = useTransform(nd, (d) => 1 - d * 0.14 * falloff);\n  const opacity = useTransform(nd, (d) => clamp(1 - d * 0.3 * falloff, 0.4, 1));\n  const blurPx = useTransform(nd, (d) =>\n    reduce ? 0 : Math.min(d * 3.2, 5) * falloff,\n  );\n  const filter = useMotionTemplate`blur(${blurPx}px)`;\n\n  return (\n    <motion.div\n      className=\"relative shrink-0\"\n      style={{ width, scale, opacity, filter }}\n    >\n      <div className=\"aspect-[3/4] size-full overflow-hidden rounded-2xl border border-border bg-card shadow-xl\">\n        {children}\n      </div>\n    </motion.div>\n  );\n};\n\nconst InertiaGallery = React.forwardRef<HTMLDivElement, InertiaGalleryProps>(\n  (\n    {\n      children,\n      itemWidth = 200,\n      gap = 24,\n      snap = false,\n      falloff = 1,\n      defaultIndex = 0,\n      onChange,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduce = useReducedMotion() ?? false;\n    const items = React.Children.toArray(children);\n    const count = items.length;\n    const pitch = itemWidth + gap;\n    const effFalloff = reduce ? 0 : falloff;\n    const start = Math.max(0, Math.min(count - 1, defaultIndex));\n\n    const x = useMotionValue(-start * pitch);\n    const [active, setActive] = React.useState(start);\n    const [pad, setPad] = React.useState(0);\n    const viewportRef = React.useRef<HTMLDivElement>(null);\n\n    const onChangeRef = React.useRef(onChange);\n    onChangeRef.current = onChange;\n\n    // Center the first/last slide by padding the track by half the free space.\n    React.useEffect(() => {\n      const el = viewportRef.current;\n      if (!el) return;\n      const measure = () =>\n        setPad(Math.max(0, (el.clientWidth - itemWidth) / 2));\n      measure();\n      const ro = new ResizeObserver(measure);\n      ro.observe(el);\n      return () => ro.disconnect();\n    }, [itemWidth]);\n\n    const commit = React.useCallback(\n      (index: number) => {\n        const next = clamp(index, 0, count - 1);\n        setActive((prev) => {\n          if (prev !== next) onChangeRef.current?.(next);\n          return next;\n        });\n        return next;\n      },\n      [count],\n    );\n\n    const goTo = React.useCallback(\n      (index: number) => {\n        const next = commit(index);\n        animate(x, -next * pitch, SETTLE_SPRING);\n      },\n      [commit, pitch, x],\n    );\n\n    const nearest = React.useCallback(\n      () => clamp(Math.round(-x.get() / pitch), 0, count - 1),\n      [count, pitch, x],\n    );\n\n    const handleDragEnd = () => {\n      if (snap) goTo(nearest());\n      else commit(nearest());\n    };\n\n    const handleKey = (e: React.KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        goTo(active - 1);\n      } else if (e.key === \"ArrowRight\") {\n        e.preventDefault();\n        goTo(active + 1);\n      }\n    };\n\n    return (\n      <div\n        ref={forwardedRef}\n        data-slot=\"inertia-gallery\"\n        className={`flex flex-col items-center gap-5 ${className ?? \"\"}`}\n        {...props}\n      >\n        {/* biome-ignore lint/a11y/useSemanticElements: composite carousel widget */}\n        <div\n          ref={viewportRef}\n          role=\"group\"\n          aria-roledescription=\"carousel\"\n          aria-label=\"Gallery\"\n          className=\"relative w-full max-w-full touch-pan-y overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]\"\n        >\n          <motion.div\n            className=\"flex cursor-grab items-center active:cursor-grabbing\"\n            style={{ x, gap, paddingLeft: pad, paddingRight: pad }}\n            drag=\"x\"\n            dragConstraints={{ left: -(count - 1) * pitch, right: 0 }}\n            dragElastic={0.16}\n            onDragEnd={handleDragEnd}\n          >\n            {items.map((child, i) => (\n              <InertiaItem\n                // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional\n                key={i}\n                offset={i * pitch}\n                pitch={pitch}\n                x={x}\n                falloff={effFalloff}\n                reduce={reduce}\n                width={itemWidth}\n              >\n                {child}\n              </InertiaItem>\n            ))}\n          </motion.div>\n        </div>\n\n        <div className=\"flex items-center gap-4\">\n          <button\n            type=\"button\"\n            onClick={() => goTo(active - 1)}\n            onKeyDown={handleKey}\n            disabled={active === 0}\n            aria-label=\"Previous\"\n            className=\"grid size-10 place-items-center rounded-full border border-border bg-card text-foreground shadow-sm [transition:transform_150ms,background_150ms] hover:bg-accent active:scale-95 disabled:opacity-40\"\n          >\n            ‹\n          </button>\n          <span className=\"text-sm tabular-nums text-muted-foreground\">\n            {active + 1} / {count}\n          </span>\n          <button\n            type=\"button\"\n            onClick={() => goTo(active + 1)}\n            onKeyDown={handleKey}\n            disabled={active === count - 1}\n            aria-label=\"Next\"\n            className=\"grid size-10 place-items-center rounded-full border border-border bg-card text-foreground shadow-sm [transition:transform_150ms,background_150ms] hover:bg-accent active:scale-95 disabled:opacity-40\"\n          >\n            ›\n          </button>\n        </div>\n      </div>\n    );\n  },\n);\nInertiaGallery.displayName = \"InertiaGallery\";\n\nexport { InertiaGallery };\n",
      "type": "registry:ui",
      "target": "components/godui/inertia-gallery.tsx"
    }
  ],
  "type": "registry:ui"
}
