{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-trail-loader",
  "type": "registry:ui",
  "title": "Cursor Trail Loader",
  "description": "A rolling loading counter with a cursor-driven image trail and a smooth upward content reveal.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.devclubxnst.com/r/animated-counter.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/cursor-trail-loader.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { AnimatedCounter } from \"./animated-counter\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface CursorTrailLoaderProps {\n  images: string[];\n  children?: ReactNode;\n  progress?: number;\n  duration?: number;\n  revealDuration?: number;\n  imageSize?: number;\n  imageAspectRatio?: number;\n  trailLength?: number;\n  trailSpacing?: number;\n  trailLifetime?: number;\n  followDuration?: number;\n  rotation?: number;\n  borderRadius?: number;\n  background?: string;\n  foreground?: string;\n  paused?: boolean;\n  replayKey?: string | number;\n  label?: string;\n  className?: string;\n  onComplete?: () => void;\n  layout?: \"trail\" | \"grid\";\n  messages?: readonly string[];\n  gridColumns?: number;\n  gridRows?: number;\n  gridOpacity?: number;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function CursorTrailLoader({\n  images,\n  children,\n  progress,\n  duration = 4.2,\n  revealDuration = 0.9,\n  imageSize = 160,\n  imageAspectRatio = 0.8,\n  trailLength = 10,\n  trailSpacing = 55,\n  trailLifetime = 0.8,\n  followDuration = 0.2,\n  rotation = 18,\n  borderRadius = 3,\n  background = \"#f0efe9\",\n  foreground = \"#171717\",\n  paused = false,\n  replayKey = 0,\n  label = \"Loading\",\n  className,\n  onComplete,\n  layout = \"trail\",\n  messages = [\"Loading\"],\n  gridColumns = 4,\n  gridRows = 4,\n  gridOpacity = 0.08,\n}: CursorTrailLoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const counterRef = useRef<HTMLDivElement>(null);\n  const controlRef = useRef<{\n    progress: (value: number) => void;\n    pause: (value: boolean) => void;\n  } | null>(null);\n  const callbackRef = useRef(onComplete);\n  const progressRef = useRef(progress);\n  const pausedRef = useRef(paused);\n  const finishedRef = useRef(false);\n  const [complete, setComplete] = useState(false);\n  const [counter, setCounter] = useState(0);\n  const automatic = progress === undefined;\n  const sourceKey = JSON.stringify(images);\n  const phrases = messages.filter((message) => message.trim()).slice(0, 12);\n  if (!phrases.length) phrases.push(label);\n  const messageKey = JSON.stringify(phrases);\n  const columns = Math.round(bounded(gridColumns, 4, 2, 8));\n  const rows = Math.round(bounded(gridRows, 4, 2, 8));\n  const grid = layout === \"grid\";\n  const loadingDuration = bounded(duration, 4.2, 0.5, 30);\n  const exitDuration = bounded(revealDuration, 0.9, 0.2, 3);\n  const size = bounded(imageSize, 160, 60, 360);\n  const ratio = bounded(imageAspectRatio, 0.8, 0.5, 2);\n  const count = Math.round(bounded(trailLength, 10, 1, 18));\n  const spacing = bounded(trailSpacing, 55, 15, 180);\n  const lifetime = bounded(trailLifetime, 0.8, 0.15, 3);\n  const follow = bounded(followDuration, 0.2, 0.05, 0.6);\n  const tilt = bounded(rotation, 18, 0, 40);\n  const radius = bounded(borderRadius, 3, 0, 32);\n\n  useEffect(() => {\n    callbackRef.current = onComplete;\n    progressRef.current = progress;\n    pausedRef.current = paused;\n    controlRef.current?.pause(paused);\n  }, [onComplete, progress, paused]);\n\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      finishedRef.current = false;\n      setComplete(false);\n      setCounter(0);\n\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n          hover: \"(hover: hover) and (pointer: fine)\",\n        },\n        (context) => {\n          const root = rootRef.current;\n          const overlay = overlayRef.current;\n          const content = contentRef.current;\n          const counterElement = counterRef.current;\n          if (!root || !overlay || !content || !counterElement) return;\n\n          const reduced = Boolean(context.conditions?.reduced);\n          const cards = gsap.utils.toArray<HTMLElement>(\n            \"[data-cursor-trail-card]\",\n            root,\n          );\n          const assets = gsap.utils.toArray<HTMLImageElement>(\n            \"[data-cursor-trail-asset]\",\n            root,\n          );\n          const message = root.querySelector<HTMLElement>(\n            \"[data-grid-loader-message]\",\n          );\n          let messageIndex = -1;\n          let messageAnimation: gsap.core.Timeline | null = null;\n          let active = true;\n          let revealing = finishedRef.current;\n          let waiting = false;\n          let bounds = root.getBoundingClientRect();\n          let imageWidth = 0;\n          let imageHeight = 0;\n          let index = 0;\n          let anchor: { x: number; y: number } | null = null;\n          let previous: { x: number; y: number } | null = null;\n          let activeCell: string | null = null;\n\n          const finish = () => {\n            if (!active || finishedRef.current) return;\n            finishedRef.current = true;\n            setComplete(true);\n            callbackRef.current?.();\n          };\n          const showProgress = context.add(\"showProgress\", (value: number) => {\n            setCounter(Math.round(value));\n            overlay.setAttribute(\"aria-valuenow\", String(Math.round(value)));\n            if (!grid || !message) return;\n            const next = Math.min(\n              phrases.length - 1,\n              Math.floor(value / 100 * phrases.length),\n            );\n            if (next === messageIndex) return;\n            messageAnimation?.kill();\n            if (messageIndex === -1 || reduced) {\n              message.textContent = phrases[next];\n              gsap.set(message, { autoAlpha: 1, y: 0, yPercent: 0 });\n            } else {\n              messageAnimation = gsap\n                .timeline({\n                  defaults: { ease: \"power3.out\" },\n                  paused: pausedRef.current,\n                })\n                .to(message, { autoAlpha: 0, yPercent: -30, duration: 0.12 })\n                .call(() => {\n                  message.textContent = phrases[next];\n                })\n                .set(message, { autoAlpha: 0, y: 0, yPercent: 30 })\n                .to(message, { autoAlpha: 1, y: 0, yPercent: 0, duration: 0.24 })\n                .set(message, { autoAlpha: 1, y: 0, yPercent: 0 });\n            }\n            messageIndex = next;\n          }) as (value: number) => void;\n\n          gsap.set(overlay, {\n            autoAlpha: finishedRef.current ? 0 : 1,\n            yPercent: 0,\n          });\n          gsap.set(content, {\n            y: reduced || finishedRef.current ? 0 : 24,\n          });\n          gsap.set(counterElement, { autoAlpha: 1, y: 0, yPercent: 0 });\n          gsap.set(cards, { autoAlpha: 0, xPercent: -50, yPercent: -50 });\n          showProgress(automatic ? 0 : progressRef.current ?? 0);\n\n          const measure = context.add(\"measure\", () => {\n            bounds = root.getBoundingClientRect();\n            const nextWidth = grid\n              ? bounds.width / columns\n              : Math.min(size, bounds.width * 0.38, bounds.height * ratio * 0.55);\n            const nextHeight = grid ? bounds.height / rows : nextWidth / ratio;\n            if (nextWidth === imageWidth && nextHeight === imageHeight) return;\n            imageWidth = nextWidth;\n            imageHeight = nextHeight;\n            gsap.set(cards, { width: imageWidth, height: imageHeight });\n            if (grid) {\n              cards.forEach((card) => {\n                if (!card.hasAttribute(\"data-grid-column\")) return;\n                gsap.set(card, {\n                  x: (Number(card.dataset.gridColumn) + 0.5) * imageWidth,\n                  y: (Number(card.dataset.gridRow) + 0.5) * imageHeight,\n                });\n              });\n            }\n          }) as () => void;\n          measure();\n\n          const trails = cards.map((card) => {\n            const place = gsap.quickSetter(card, \"css\");\n            const xTo = gsap.quickTo(card, \"x\", {\n              duration: follow,\n              ease: \"power3.out\",\n            });\n            const yTo = gsap.quickTo(card, \"y\", {\n              duration: follow,\n              ease: \"power3.out\",\n            });\n            const animation = gsap\n              .timeline({ paused: true })\n              .fromTo(\n                card,\n                { autoAlpha: 0, scale: grid ? 0.96 : 0.65 },\n                { autoAlpha: 1, scale: 1, duration: 0.22, ease: \"power3.out\" },\n              )\n              .to(\n                card,\n                {\n                  autoAlpha: 0,\n                  scale: grid ? 1 : 0.92,\n                  duration: 0.5,\n                  ease: \"power2.inOut\",\n                },\n                0.22 + lifetime,\n              );\n            return { card, place, xTo, yTo, animation, used: false };\n          });\n\n          const spawn = (event: PointerEvent) => {\n            if (\n              revealing ||\n              finishedRef.current ||\n              pausedRef.current ||\n              event.pointerType === \"touch\"\n            ) return;\n            const point = {\n              x: event.clientX - bounds.left,\n              y: event.clientY - bounds.top,\n            };\n            const dx = point.x - (previous?.x ?? point.x);\n            const dy = point.y - (previous?.y ?? point.y);\n            previous = point;\n            const column = gsap.utils.clamp(\n              0,\n              columns - 1,\n              Math.floor(point.x / imageWidth),\n            );\n            const row = gsap.utils.clamp(\n              0,\n              rows - 1,\n              Math.floor(point.y / imageHeight),\n            );\n            const cell = `${column}-${row}`;\n            if (grid && activeCell === cell) return;\n            if (\n              !grid &&\n              anchor &&\n              Math.hypot(point.x - anchor.x, point.y - anchor.y) < spacing\n            ) return;\n            const available = assets.filter(\n              (image) => image.complete && image.naturalWidth > 0,\n            );\n            if (!available.length) return;\n            const trail = trails[index % trails.length];\n            const image = trail.card.querySelector(\"img\");\n            if (!image) return;\n            image.src = available[index % available.length].src;\n            const x = gsap.utils.clamp(\n              imageWidth * 0.6,\n              bounds.width - imageWidth * 0.6,\n              point.x,\n            );\n            const y = gsap.utils.clamp(\n              imageWidth / ratio * 0.6,\n              bounds.height - imageWidth / ratio * 0.6,\n              point.y,\n            );\n            const angle = gsap.utils.clamp(\n              -tilt,\n              tilt,\n              dx * 0.4 + (index % 3 - 1) * tilt * 0.45,\n            );\n            if (grid) {\n              trail.card.dataset.gridColumn = String(column);\n              trail.card.dataset.gridRow = String(row);\n              trail.place({\n                rotation: 0,\n                zIndex: ++index,\n                x: (column + 0.5) * imageWidth,\n                y: (row + 0.5) * imageHeight,\n              });\n              activeCell = cell;\n            } else {\n              trail.place({ rotation: angle, zIndex: ++index });\n              trail.xTo(x, x - dx * 0.4);\n              trail.yTo(y, y - dy * 0.4);\n            }\n            trail.used = true;\n            trail.animation.restart();\n            anchor = point;\n          };\n          const enter = (event: PointerEvent) => {\n            bounds = root.getBoundingClientRect();\n            anchor = null;\n            previous = null;\n            activeCell = null;\n            spawn(event);\n          };\n          const leave = () => {\n            anchor = null;\n            previous = null;\n            activeCell = null;\n          };\n\n          if (context.conditions?.hover && !reduced) {\n            overlay.addEventListener(\"pointerenter\", enter);\n            overlay.addEventListener(\"pointermove\", spawn, { passive: true });\n            overlay.addEventListener(\"pointerleave\", leave);\n          }\n\n          const timeline = gsap.timeline({ paused: true, onComplete: finish });\n          if (!reduced) {\n            timeline.fromTo(\n              counterElement,\n              { yPercent: 110 },\n              { yPercent: 0, duration: 0.65, ease: \"power3.out\" },\n            );\n          }\n          if (automatic && !reduced) {\n            if (grid) {\n              phrases.forEach((_, stage) => {\n                timeline.call(\n                  () =>\n                    showProgress(\n                      Math.min(99, Math.ceil(stage / phrases.length * 100)),\n                    ),\n                  [],\n                  loadingDuration * stage / phrases.length,\n                );\n              });\n              timeline.call(\n                () => showProgress(100),\n                [],\n                loadingDuration * 0.98,\n              );\n            } else {\n              [24, 55, 76, 100].forEach((value, stage) => {\n                timeline.call(\n                  () => showProgress(value),\n                  [],\n                  loadingDuration * [0.22, 0.47, 0.7, 0.9][stage],\n                );\n              });\n            }\n          }\n          timeline.addLabel(\n            \"reveal\",\n            automatic && !reduced ? loadingDuration : reduced ? 0 : 0.65,\n          );\n          if (!automatic && (progressRef.current ?? 0) < 100) {\n            timeline.addPause(\"reveal\", () => {\n              waiting = true;\n            });\n          }\n          timeline\n            .to({}, { duration: reduced ? 0 : 0.4 }, \"reveal\")\n            .call(() => {\n              revealing = true;\n              showProgress(100);\n              messageAnimation?.kill();\n              trails.forEach(({ animation, xTo, yTo }) => {\n                animation.pause();\n                xTo.tween.pause();\n                yTo.tween.pause();\n              });\n            })\n            .addLabel(\"exit\")\n            .to(\n              cards,\n              { autoAlpha: 0, scale: 0.85, duration: reduced ? 0 : 0.3 },\n              \"exit\",\n            )\n            .to(\n              counterElement,\n              {\n                autoAlpha: 0,\n                yPercent: -110,\n                duration: reduced ? 0 : 0.35,\n                ease: \"power3.in\",\n              },\n              \"exit\",\n            )\n            .to(\n              overlay,\n              {\n                yPercent: -100,\n                duration: reduced ? 0 : exitDuration,\n                ease: \"power3.inOut\",\n              },\n              reduced ? \"exit\" : \"exit+=0.18\",\n            )\n            .to(\n              content,\n              {\n                y: 0,\n                duration: reduced ? 0 : exitDuration,\n                ease: \"power3.out\",\n              },\n              \"<\",\n            )\n            .set(overlay, { autoAlpha: 0 });\n\n          const update = (value: number) => {\n            if (automatic || revealing || finishedRef.current) return;\n            const target = bounded(value, 0, 0, 100);\n            showProgress(target);\n            if (target === 100) {\n              waiting = false;\n              timeline.removePause(\"reveal\");\n              if (!pausedRef.current) timeline.play();\n            }\n          };\n          const pause = (value: boolean) => {\n            if (value || !waiting) timeline.paused(value);\n            messageAnimation?.paused(value);\n            trails.forEach(({ animation, xTo, yTo, used }) => {\n              if (used && (value || !revealing)) {\n                animation.paused(value);\n                xTo.tween.paused(value);\n                yTo.tween.paused(value);\n              }\n            });\n          };\n          controlRef.current = { progress: update, pause };\n          if (finishedRef.current) {\n            gsap.set(overlay, { autoAlpha: 0 });\n          } else {\n            if (!automatic) update(progressRef.current ?? 0);\n            if (!pausedRef.current) timeline.play();\n          }\n\n          const observer = new ResizeObserver(measure);\n          observer.observe(root);\n          window.addEventListener(\"scroll\", measure, { passive: true, capture: true });\n\n          return () => {\n            active = false;\n            messageAnimation?.kill();\n            controlRef.current = null;\n            observer.disconnect();\n            window.removeEventListener(\"scroll\", measure, true);\n            overlay.removeEventListener(\"pointerenter\", enter);\n            overlay.removeEventListener(\"pointermove\", spawn);\n            overlay.removeEventListener(\"pointerleave\", leave);\n          };\n        },\n        rootRef,\n      );\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        sourceKey,\n        messageKey,\n        grid,\n        columns,\n        rows,\n        automatic,\n        loadingDuration,\n        exitDuration,\n        size,\n        ratio,\n        count,\n        spacing,\n        lifetime,\n        follow,\n        tilt,\n        radius,\n        replayKey,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  useEffect(() => {\n    if (progress !== undefined) controlRef.current?.progress(progress);\n  }, [progress]);\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot={grid ? \"grid-image-loader\" : \"cursor-trail-loader\"}\n      aria-busy={!complete}\n      className={cn(\n        \"relative isolate min-h-80 w-full overflow-hidden [container-type:inline-size]\",\n        className,\n      )}\n    >\n      <div\n        ref={contentRef}\n        inert={!complete}\n        aria-hidden={!complete}\n        className={cn(\"relative min-h-[inherit]\", grid && \"h-full\")}\n      >\n        {children}\n      </div>\n      <div\n        ref={overlayRef}\n        role=\"progressbar\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={counter}\n        aria-hidden={complete}\n        className={cn(\n          \"absolute inset-0 z-20 overflow-hidden\",\n          complete && \"pointer-events-none\",\n        )}\n        style={{ backgroundColor: background, color: foreground }}\n      >\n        {grid && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0\"\n            style={{\n              backgroundImage:\n                \"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)\",\n              backgroundSize: `${100 / columns}% ${100 / rows}%`,\n              opacity: bounded(gridOpacity, 0.08, 0, 0.3),\n            }}\n          />\n        )}\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-10\">\n          <div className=\"hidden\">\n            {images.map((src, index) => (\n              <img\n                key={`${src}-${index}`}\n                data-cursor-trail-asset\n                src={src}\n                alt=\"\"\n                loading=\"eager\"\n                decoding=\"async\"\n              />\n            ))}\n          </div>\n          {Array.from({ length: count }, (_, index) => (\n            <div\n              key={index}\n              data-cursor-trail-card\n              className=\"invisible absolute top-0 left-0 overflow-hidden opacity-0 will-change-transform\"\n              style={{ borderRadius: radius }}\n            >\n              <img alt=\"\" className=\"h-full w-full object-cover\" decoding=\"async\" />\n            </div>\n          ))}\n        </div>\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute z-30 overflow-hidden\",\n            grid\n              ? \"inset-0 flex items-center justify-center px-[6%]\"\n              : \"right-[6%] bottom-[5%]\",\n          )}\n        >\n          <div\n            ref={counterRef}\n            className={\n              grid\n                ? \"w-full text-center text-[clamp(1rem,4.5cqw,3.5rem)] leading-[1.15] font-semibold tracking-[-0.035em] uppercase\"\n                : \"text-[clamp(2.75rem,10cqw,7.5rem)] leading-none font-semibold tracking-[-0.07em] tabular-nums\"\n            }\n          >\n            {grid ? (\n              <span data-grid-loader-message className=\"block text-balance\">\n                {phrases[0]}\n              </span>\n            ) : (\n              <AnimatedCounter\n                value={counter}\n                padStart={2}\n                separator=\"\"\n                duration={0.4}\n                gooey={false}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {complete ? \"Content ready\" : label}\n      </span>\n    </div>\n  );\n}\n\nexport default CursorTrailLoader;\n",
      "type": "registry:ui",
      "target": "@ui/cursor-trail-loader.tsx"
    }
  ]
}
