{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-trail",
  "title": "Image Trail",
  "description": "Moving the pointer leaves a trail of images behind it. Each card rises under the cursor, tilts with the direction of travel, hangs for a moment and drifts away. Recycles a fixed pool of cards, keeps quiet zones clear for headlines and buttons, loads images lazily, mouse and pen only, with reduced-motion support.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/image-trail/image-trail.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TrailImage {\n  src: string\n  /** Not announced: the trail is decorative and hidden from assistive tech. */\n  alt?: string\n}\n\nexport interface ImageTrailProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Images to drop along the pointer path, used in order and looped. */\n  images: readonly TrailImage[]\n  /** Pointer travel in px between two cards. Smaller is denser. Default 80. */\n  spacing?: number\n  /** Life of one card in ms: rise, hang, drift away. Default 1500. */\n  duration?: number\n  /** Most cards on screen at once. Cards are recycled, so the DOM never grows. Default 14. */\n  maxCards?: number\n  /** Largest tilt in degrees, following the direction of travel. `0` keeps cards upright. Default 16. */\n  tilt?: number\n  /** Selector for areas that never spawn cards, such as headlines and buttons. Default `[data-trail-quiet]`. */\n  quietSelector?: string\n  /** Classes for every card: size, aspect ratio, radius, shadow. */\n  cardClassName?: string\n}\n\nconst EASE_OUT = \"cubic-bezier(0.22, 1, 0.36, 1)\"\n\n/**\n * Cursor image trail. Every `spacing` px of pointer travel a card rises under\n * the cursor, tilts with the direction of travel, hangs for a moment and drifts\n * away. Drop it inside any positioned element; it listens on its parent.\n * Mouse and pen only, so touch scrolling stays clean. With reduced motion the\n * cards fade in place without moving.\n */\nexport function ImageTrail({\n  images,\n  spacing = 80,\n  duration = 1500,\n  maxCards = 14,\n  tilt = 16,\n  quietSelector = \"[data-trail-quiet]\",\n  cardClassName,\n  className,\n  ...props\n}: ImageTrailProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  // Read the latest images at spawn time without re-binding listeners.\n  const imagesRef = React.useRef(images)\n  React.useEffect(() => {\n    imagesRef.current = images\n  })\n\n  const count = Math.max(1, Math.round(maxCards))\n\n  React.useEffect(() => {\n    const host = ref.current\n    const stage = host?.parentElement\n    if (!host || !stage) return\n\n    const cards = Array.from(host.children) as HTMLElement[]\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    let n = 0\n    let lx = Number.NaN\n    let ly = Number.NaN\n    const warm = new Set<string>()\n\n    // Nothing loads until it can be used: warm the images once the page is idle,\n    // and only where a mouse or pen exists. Touch-only devices never fetch them.\n    const preload = () => {\n      imagesRef.current.forEach(({ src }) => {\n        if (warm.has(src)) return\n        warm.add(src)\n        const img = new Image()\n        img.decoding = \"async\"\n        img.src = src\n      })\n    }\n    const idle = window.matchMedia(\"(any-pointer: fine)\").matches\n      ? window.setTimeout(() => {\n          if (typeof window.requestIdleCallback === \"function\") window.requestIdleCallback(preload, { timeout: 2000 })\n          else preload()\n        }, 1000)\n      : undefined\n\n    const inQuietZone = (cx: number, cy: number) => {\n      const m = cards[0].offsetWidth / 2\n      return Array.from(stage.querySelectorAll<HTMLElement>(quietSelector)).some((zone) => {\n        const q = zone.getBoundingClientRect()\n        return cx > q.left - m && cx < q.right + m && cy > q.top - m && cy < q.bottom + m\n      })\n    }\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return\n      const list = imagesRef.current\n      if (!list.length) return\n\n      const r = host.getBoundingClientRect()\n      const x = e.clientX - r.left\n      const y = e.clientY - r.top\n      if (Number.isNaN(lx)) {\n        preload()\n        lx = x\n        ly = y\n        return\n      }\n      const dx = x - lx\n      const dy = y - ly\n      if (Math.hypot(dx, dy) < spacing) return\n      if (inQuietZone(e.clientX, e.clientY)) {\n        lx = Number.NaN\n        ly = Number.NaN\n        return\n      }\n      lx = x\n      ly = y\n\n      const el = cards[n % cards.length]\n      const img = el.firstElementChild as HTMLImageElement\n      const src = list[n % list.length].src\n      if (img.getAttribute(\"src\") !== src) img.src = src\n      n += 1\n\n      el.getAnimations().forEach((a) => a.cancel())\n      el.style.zIndex = String(n)\n      const at = `translate(${x}px, ${y}px) translate(-50%, -50%)`\n\n      if (reduce.matches) {\n        el.animate(\n          [\n            { transform: at, opacity: 0 },\n            { transform: at, opacity: 1, offset: 0.2 },\n            { transform: at, opacity: 0 },\n          ],\n          { duration, easing: \"ease\", fill: \"both\" }\n        )\n        return\n      }\n\n      const t = Math.max(-tilt, Math.min(tilt, dx * 0.12))\n      const lift = -40 - Math.min(40, Math.abs(dy) * 0.25)\n      el.animate(\n        [\n          { transform: `${at} translateY(28px) rotate(${t}deg) scale(0.55)`, opacity: 0 },\n          { transform: `${at} rotate(${t * 0.6}deg)`, opacity: 1, offset: 0.16 },\n          { transform: `${at} translateY(-14px) rotate(${t * 0.4}deg)`, opacity: 1, offset: 0.62 },\n          { transform: `${at} translateY(${lift}px) rotate(${-t * 0.25}deg) scale(0.9)`, opacity: 0 },\n        ],\n        { duration, easing: EASE_OUT, fill: \"both\" }\n      )\n    }\n    const onLeave = () => {\n      lx = Number.NaN\n      ly = Number.NaN\n    }\n\n    stage.addEventListener(\"pointermove\", onMove, { passive: true })\n    stage.addEventListener(\"pointerleave\", onLeave)\n    return () => {\n      stage.removeEventListener(\"pointermove\", onMove)\n      stage.removeEventListener(\"pointerleave\", onLeave)\n      window.clearTimeout(idle)\n      cards.forEach((el) => el.getAnimations().forEach((a) => a.cancel()))\n    }\n  }, [spacing, duration, tilt, quietSelector, count])\n\n  return (\n    <div\n      ref={ref}\n      aria-hidden\n      data-slot=\"image-trail\"\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)}\n      {...props}\n    >\n      {Array.from({ length: count }, (_, i) => (\n        <div\n          key={i}\n          className={cn(\n            \"absolute top-0 left-0 aspect-[4/5] w-[clamp(110px,11vw,168px)] overflow-hidden rounded-[3px] opacity-0 shadow-[0_30px_50px_-18px_rgba(0,0,0,0.5)] ring-1 ring-black/10 dark:ring-white/10\",\n            cardClassName\n          )}\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img\n            alt=\"\"\n            draggable={false}\n            decoding=\"async\"\n            className=\"size-full object-cover\"\n          />\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-24"
  },
  "type": "registry:component"
}