{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "cursor-trail-loader",
  "name": "Cursor Trail Loader",
  "title": "Cursor Trail Loader",
  "type": "registry:ui",
  "description": "A rolling loading counter with a cursor-driven image trail and a smooth upward content reveal.",
  "summary": "Move the cursor while loading to leave a layered trail of images that ease into place, tilt with movement, and fade away. A rolling counter settles at 100 before the loading curtain lifts to reveal the page. Configure image geometry, trail density, timing, colors, progress, and replay.",
  "category": "loaders",
  "tags": [
    "loader",
    "cursor",
    "images",
    "trail",
    "hover",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "animated-counter"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-03",
  "updatedDate": "2026-10-03",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Cursor-driven image trail with velocity-based tilt",
    "Reusable bounded image pool with smooth fading",
    "Existing Animated Counter reused for rolling digits",
    "Automatic and controlled progress with pause and replay",
    "Reduced-motion fallback and scoped animation cleanup"
  ],
  "anatomy": [
    "<CursorTrailLoader> loading container",
    "Decorative cursor image pool",
    "Rolling progress counter in the lower-right corner",
    "Upward loading curtain",
    "Content revealed after loading"
  ],
  "physics": {
    "engine": "GSAP + existing Animated Counter",
    "description": "GSAP quickTo reuses pointer-follow tweens. Each image card has a reusable entrance, hold, and fade timeline. Distance-based spawning limits trail density. A scoped master timeline controls progress stages and the curtain reveal; the existing Animated Counter handles rolling digits.",
    "parameters": [
      {
        "label": "Loading",
        "value": "4.2s"
      },
      {
        "label": "Pointer smoothing",
        "value": "0.2s · power3.out"
      },
      {
        "label": "Trail spacing",
        "value": "55px"
      },
      {
        "label": "Reveal",
        "value": "0.9s · power3.inOut"
      }
    ]
  },
  "accessibility": {
    "role": "progressbar",
    "aria": "Loading progress is labeled. Trail images are decorative and pointer-transparent. Page content stays inert until loading completes.",
    "reducedMotion": "Cursor trails and curtain movement are disabled. Automatic loading reveals content immediately; controlled loading waits for progress to reach 100."
  },
  "guidelines": {
    "recommended": [
      "Portfolio introductions",
      "Image-led landing pages",
      "Interactive loading screens"
    ],
    "bestPractices": [
      "Connect progress to real loading work when used in production.",
      "Keep automatic loading short and provide pause or replay for demos.",
      "Cursor trails require a fine hover pointer; loading and content reveal also work without hover.",
      "Provide accessible content through children."
    ]
  },
  "props": [
    {
      "name": "images",
      "type": "string[]",
      "description": "Image URLs for the decorative cursor trail. Loaded images cycle in order; failed images are skipped.",
      "required": true
    },
    {
      "name": "children",
      "type": "ReactNode",
      "description": "Content revealed when loading completes."
    },
    {
      "name": "progress",
      "type": "number",
      "description": "Controlled loading progress from 0 to 100. Omit for automatic progress."
    },
    {
      "name": "duration",
      "type": "number",
      "description": "Automatic loading duration in seconds.",
      "defaultValue": "4.2"
    },
    {
      "name": "revealDuration",
      "type": "number",
      "description": "Duration of the upward curtain reveal in seconds.",
      "defaultValue": "0.9"
    },
    {
      "name": "imageSize",
      "type": "number",
      "description": "Trail image width in pixels, constrained to the container.",
      "defaultValue": "160"
    },
    {
      "name": "imageAspectRatio",
      "type": "number",
      "description": "Image width divided by height.",
      "defaultValue": "0.8"
    },
    {
      "name": "trailLength",
      "type": "number",
      "description": "Maximum number of image cards in the reusable trail pool.",
      "defaultValue": "10"
    },
    {
      "name": "trailSpacing",
      "type": "number",
      "description": "Cursor travel distance in pixels before the next image appears.",
      "defaultValue": "55"
    },
    {
      "name": "trailLifetime",
      "type": "number",
      "description": "Image hold before fading, in seconds.",
      "defaultValue": "0.8"
    },
    {
      "name": "followDuration",
      "type": "number",
      "description": "Cursor-follow smoothing duration in seconds.",
      "defaultValue": "0.2"
    },
    {
      "name": "rotation",
      "type": "number",
      "description": "Maximum image tilt in degrees.",
      "defaultValue": "18"
    },
    {
      "name": "borderRadius",
      "type": "number",
      "description": "Image corner radius in pixels.",
      "defaultValue": "3"
    },
    {
      "name": "background",
      "type": "string",
      "description": "Loading curtain background color.",
      "defaultValue": "\"#f0efe9\""
    },
    {
      "name": "foreground",
      "type": "string",
      "description": "Loading counter color.",
      "defaultValue": "\"#171717\""
    },
    {
      "name": "paused",
      "type": "boolean",
      "description": "Pause or resume the loader and active image animations.",
      "defaultValue": "false"
    },
    {
      "name": "replayKey",
      "type": "string | number",
      "description": "Change to restart loading from zero.",
      "defaultValue": "0"
    },
    {
      "name": "label",
      "type": "string",
      "description": "Accessible progress label.",
      "defaultValue": "\"Loading\""
    },
    {
      "name": "className",
      "type": "string",
      "description": "Container size and styling classes."
    },
    {
      "name": "onComplete",
      "type": "() => void",
      "description": "Called once after the content is revealed."
    },
    {
      "name": "layout",
      "type": "\"trail\" | \"grid\"",
      "description": "Use a floating cursor trail or images snapped to a cell grid.",
      "defaultValue": "\"trail\""
    },
    {
      "name": "messages",
      "type": "readonly string[]",
      "description": "Phrases shown in the grid layout. Empty phrases are skipped; up to 12 are supported.",
      "defaultValue": "[\"Loading\"]"
    },
    {
      "name": "gridColumns",
      "type": "number",
      "description": "Number of grid columns, bounded to 2–8.",
      "defaultValue": "4"
    },
    {
      "name": "gridRows",
      "type": "number",
      "description": "Number of grid rows, bounded to 2–8.",
      "defaultValue": "4"
    },
    {
      "name": "gridOpacity",
      "type": "number",
      "description": "Grid line opacity, bounded to 0–0.3.",
      "defaultValue": "0.08"
    }
  ],
  "usage": "import { CursorTrailLoader } from \"@/components/ui/cursor-trail-loader\";\n\nconst images = [\"/images/trail-1.jpg\", \"/images/trail-2.jpg\", \"/images/trail-3.jpg\"];\n\nexport function CursorTrailLoaderDemo() {\n  return (\n    <CursorTrailLoader images={images} className=\"h-[480px]\">\n      <h1>Welcome</h1>\n    </CursorTrailLoader>\n  );\n}",
  "files": [
    {
      "name": "cursor-trail-loader.tsx",
      "path": "registry/ui/cursor-trail-loader.tsx",
      "code": "\"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",
      "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"
    },
    {
      "name": "animated-counter.tsx",
      "path": "registry/ui/animated-counter.tsx",
      "code": "\"use client\";\n\nimport { memo, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type Transition,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Grouping = \"western\" | \"indian\";\n\nexport interface AnimatedCounterProps\n  extends Omit<\n    ComponentProps<\"span\">,\n    | \"children\"\n    | \"prefix\"\n    | \"onAnimationStart\"\n    | \"onDrag\"\n    | \"onDragStart\"\n    | \"onDragEnd\"\n  > {\n  value: number;\n  decimals?: number;\n  duration?: number;\n  padStart?: number;\n  separator?: string;\n  decimalSeparator?: string;\n  grouping?: Grouping;\n  prefix?: ReactNode;\n  suffix?: ReactNode;\n  gooey?: boolean;\n}\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst BAND = 10;\nconst WHEEL = [...DIGITS, ...DIGITS, ...DIGITS];\nconst LINE_HEIGHT = 1.5;\n\nconst MASK_GRADIENT = `linear-gradient(to bottom,\n  rgba(0,0,0,0) 0%,\n  rgba(0,0,0,0.08) 6%,\n  rgba(0,0,0,0.6) 13%,\n  rgba(0,0,0,0.96) 20%,\n  #000 28%,\n  #000 72%,\n  rgba(0,0,0,0.96) 80%,\n  rgba(0,0,0,0.6) 87%,\n  rgba(0,0,0,0.08) 94%,\n  rgba(0,0,0,0) 100%)`;\n\nconst LEAVE_TRANSITION: Transition = {\n  duration: 0.18,\n  ease: [0.22, 1, 0.36, 1],\n};\n\nconst INSTANT_TRANSITION: Transition = {\n  duration: 0,\n};\n\nconst createSpring = (duration: number): Transition => ({\n  type: \"spring\",\n  visualDuration: duration,\n  bounce: 0.16,\n});\n\nconst SIZER_NODES = DIGITS.map((digit) => (\n  <span key={digit} aria-hidden className=\"invisible [grid-area:1/1]\">\n    {digit}\n  </span>\n));\n\nconst STACK_NODES = WHEEL.map((digit, index) => (\n  <span\n    key={index}\n    className=\"flex items-center justify-center select-none\"\n    style={{ height: `${LINE_HEIGHT}em` }}\n  >\n    {digit}\n  </span>\n));\n\nconst REGEX_WESTERN = /\\B(?=(\\d{3})+(?!\\d))/g;\nconst REGEX_INDIAN = /\\B(?=(\\d{2})+(?!\\d))/g;\n\nfunction formatInteger(whole: string, separator: string, grouping: Grouping) {\n  if (!separator) return whole;\n  if (grouping !== \"indian\") return whole.replace(REGEX_WESTERN, separator);\n\n  const head = whole.slice(0, -3);\n  if (!head) return whole;\n  return `${head.replace(REGEX_INDIAN, separator)}${separator}${whole.slice(-3)}`;\n}\n\ninterface Measurement {\n  amount: number;\n  scaled: number;\n  places: number;\n  duration: number;\n  width: number;\n}\n\nfunction computeMetrics(\n  value: number,\n  decimals: number,\n  padStart: number,\n  duration: number,\n): Measurement {\n  const amount = Number.isFinite(value) ? value : 0;\n  const places = Math.min(15, Math.max(0, Math.trunc(decimals)));\n  const pad = Math.min(24, Math.max(1, Math.trunc(padStart)));\n  const scaled = Math.min(\n    Number.MAX_SAFE_INTEGER,\n    Math.round(Math.abs(amount) * 10 ** places),\n  );\n\n  return {\n    amount,\n    scaled,\n    places,\n    duration: Math.min(60, Math.max(0.01, duration)),\n    width: Math.max(String(scaled).length, places + pad),\n  };\n}\n\nfunction stringifyValue(\n  metrics: Measurement,\n  separator: string,\n  decimalSeparator: string,\n  grouping: Grouping,\n) {\n  const raw = String(metrics.scaled).padStart(metrics.width, \"0\");\n  const whole = formatInteger(\n    raw.slice(0, raw.length - metrics.places) || \"0\",\n    separator,\n    grouping,\n  );\n  return metrics.places\n    ? `${whole}${decimalSeparator}${raw.slice(raw.length - metrics.places)}`\n    : whole;\n}\n\ntype CounterCell =\n  | { type: \"digit\"; key: number; digit: number }\n  | { type: \"mark\"; key: string; char: string };\n\nfunction buildCells(formatted: string, width: number): CounterCell[] {\n  const result: CounterCell[] = [];\n  let place = 0;\n  let markIndex = 0;\n\n  for (const char of formatted) {\n    if (char >= \"0\" && char <= \"9\") {\n      markIndex = 0;\n      result.push({\n        type: \"digit\",\n        key: width - place++,\n        digit: Number(char),\n      });\n    } else {\n      result.push({\n        type: \"mark\",\n        key: `mark-${width - place}-${markIndex++}`,\n        char,\n      });\n    }\n  }\n\n  return result;\n}\n\nfunction useContinuousWheel(\n  initialDigit: number,\n  targetDigit: number,\n  direction: number,\n  duration: number,\n  reduced: boolean,\n) {\n  const pos = useMotionValue(BAND + initialDigit);\n  const headingRef = useRef(direction);\n\n  useEffect(() => {\n    headingRef.current = direction;\n  }, [direction]);\n\n  useEffect(() => {\n    if (reduced) {\n      pos.set(BAND + targetDigit);\n      return;\n    }\n\n    const current = pos.get();\n    const currentFace = ((Math.round(current) % 10) + 10) % 10;\n    let diff = targetDigit - currentFace;\n\n    if (headingRef.current > 0 && diff <= 0) diff += 10;\n    if (headingRef.current < 0 && diff >= 0) diff -= 10;\n\n    const nextTarget = current + diff;\n    const animation = animate(pos, nextTarget, createSpring(duration));\n\n    return () => {\n      animation.stop();\n      pos.set(BAND + targetDigit);\n    };\n  }, [targetDigit, duration, reduced, pos]);\n\n  return useTransform(pos, (p) => `${(-p * 100) / WHEEL.length}%`);\n}\n\ninterface DigitSlotProps {\n  reduced: boolean;\n  dep: number;\n  transition: Transition;\n  filterId?: string;\n}\n\nconst DigitColumn = memo(function DigitColumn({\n  digit,\n  from,\n  direction,\n  duration,\n  slot,\n  ref,\n}: {\n  digit: number;\n  from: number;\n  direction: number;\n  duration: number;\n  slot: DigitSlotProps;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const y = useContinuousWheel(from, digit, direction, duration, slot.reduced);\n\n  return (\n    <motion.span\n      ref={ref}\n      data-slot=\"animated-counter-digit\"\n      layout={!slot.reduced}\n      layoutDependency={slot.dep}\n      transition={slot.transition}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{\n        opacity: 0,\n        transition: slot.reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n      }}\n      className=\"relative inline-grid overflow-hidden select-none\"\n      style={{\n        height: `${LINE_HEIGHT}em`,\n        lineHeight: LINE_HEIGHT,\n        maskImage: MASK_GRADIENT,\n        WebkitMaskImage: MASK_GRADIENT,\n        filter: slot.filterId ? `url(#${slot.filterId})` : undefined,\n      }}\n    >\n      {SIZER_NODES}\n      <motion.span style={{ y }} className=\"absolute inset-x-0 top-0\">\n        {STACK_NODES}\n      </motion.span>\n    </motion.span>\n  );\n});\n\nfunction GooeyDef({ id }: { id: string }) {\n  return (\n    <svg\n      className=\"absolute w-0 h-0 pointer-events-none opacity-0 overflow-hidden\"\n      aria-hidden=\"true\"\n      tabIndex={-1}\n    >\n      <defs>\n        <filter id={id} x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"0.65\" result=\"blur\" />\n          <feColorMatrix\n            in=\"blur\"\n            mode=\"matrix\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 16 -6\"\n            result=\"goo\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n\nexport function AnimatedCounter({\n  value,\n  decimals = 0,\n  duration = 0.6,\n  padStart = 1,\n  separator = \",\",\n  decimalSeparator = \".\",\n  grouping = \"western\",\n  prefix,\n  suffix,\n  gooey = true,\n  className,\n  ...props\n}: AnimatedCounterProps) {\n  const generatedId = useId().replace(/:/g, \"_\");\n  const filterId = `gooey_${generatedId}`;\n  const reduced = useReducedMotion() ?? false;\n\n  const metrics = computeMetrics(value, decimals, padStart, duration);\n  const formatted = stringifyValue(metrics, separator, decimalSeparator, grouping);\n  const cells = buildCells(formatted, metrics.width);\n  const isNegative = metrics.amount < 0 && metrics.scaled > 0;\n\n  const [previous, setPrevious] = useState(metrics.amount);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== metrics.amount) {\n    setDirection(metrics.amount >= previous ? 1 : -1);\n    setPrevious(metrics.amount);\n  }\n\n  const [seedFaces] = useState(() => {\n    const initial: Record<number, number> = {};\n    for (const cell of cells) {\n      if (cell.type === \"digit\") initial[cell.key] = cell.digit;\n    }\n    return initial;\n  });\n\n  const springTransition = useMemo<Transition>(\n    () => (reduced ? INSTANT_TRANSITION : createSpring(metrics.duration)),\n    [reduced, metrics.duration],\n  );\n\n  const slotProps: DigitSlotProps = {\n    reduced,\n    dep: formatted.length,\n    transition: springTransition,\n    filterId: gooey && !reduced ? filterId : undefined,\n  };\n\n  return (\n    <span\n      data-slot=\"animated-counter\"\n      className={cn(\"inline-flex items-center tabular-nums relative\", className)}\n      {...props}\n    >\n      {gooey && !reduced && <GooeyDef id={filterId} />}\n\n      {prefix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {prefix}\n        </motion.span>\n      )}\n\n      <span className=\"sr-only\">\n        {isNegative ? \"-\" : \"\"}\n        {formatted}\n      </span>\n\n      <span aria-hidden className=\"inline-flex select-none items-center\">\n        {isNegative && (\n          <motion.span\n            layout={!reduced}\n            layoutDependency={formatted.length}\n            transition={springTransition}\n            className=\"inline-block\"\n          >\n            -\n          </motion.span>\n        )}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {cells.map((cell) =>\n            cell.type === \"digit\" ? (\n              <DigitColumn\n                key={cell.key}\n                digit={cell.digit}\n                from={seedFaces[cell.key] ?? cell.digit}\n                direction={direction}\n                duration={metrics.duration}\n                slot={slotProps}\n              />\n            ) : (\n              <motion.span\n                key={cell.key}\n                data-slot=\"animated-counter-mark\"\n                layout={!reduced}\n                layoutDependency={formatted.length}\n                transition={springTransition}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{\n                  opacity: 0,\n                  transition: reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n                }}\n                className=\"inline-block\"\n              >\n                {cell.char}\n              </motion.span>\n            ),\n          )}\n        </AnimatePresence>\n      </span>\n\n      {suffix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {suffix}\n        </motion.span>\n      )}\n    </span>\n  );\n}\n\nexport default AnimatedCounter;\n",
      "content": "\"use client\";\n\nimport { memo, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type Transition,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Grouping = \"western\" | \"indian\";\n\nexport interface AnimatedCounterProps\n  extends Omit<\n    ComponentProps<\"span\">,\n    | \"children\"\n    | \"prefix\"\n    | \"onAnimationStart\"\n    | \"onDrag\"\n    | \"onDragStart\"\n    | \"onDragEnd\"\n  > {\n  value: number;\n  decimals?: number;\n  duration?: number;\n  padStart?: number;\n  separator?: string;\n  decimalSeparator?: string;\n  grouping?: Grouping;\n  prefix?: ReactNode;\n  suffix?: ReactNode;\n  gooey?: boolean;\n}\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst BAND = 10;\nconst WHEEL = [...DIGITS, ...DIGITS, ...DIGITS];\nconst LINE_HEIGHT = 1.5;\n\nconst MASK_GRADIENT = `linear-gradient(to bottom,\n  rgba(0,0,0,0) 0%,\n  rgba(0,0,0,0.08) 6%,\n  rgba(0,0,0,0.6) 13%,\n  rgba(0,0,0,0.96) 20%,\n  #000 28%,\n  #000 72%,\n  rgba(0,0,0,0.96) 80%,\n  rgba(0,0,0,0.6) 87%,\n  rgba(0,0,0,0.08) 94%,\n  rgba(0,0,0,0) 100%)`;\n\nconst LEAVE_TRANSITION: Transition = {\n  duration: 0.18,\n  ease: [0.22, 1, 0.36, 1],\n};\n\nconst INSTANT_TRANSITION: Transition = {\n  duration: 0,\n};\n\nconst createSpring = (duration: number): Transition => ({\n  type: \"spring\",\n  visualDuration: duration,\n  bounce: 0.16,\n});\n\nconst SIZER_NODES = DIGITS.map((digit) => (\n  <span key={digit} aria-hidden className=\"invisible [grid-area:1/1]\">\n    {digit}\n  </span>\n));\n\nconst STACK_NODES = WHEEL.map((digit, index) => (\n  <span\n    key={index}\n    className=\"flex items-center justify-center select-none\"\n    style={{ height: `${LINE_HEIGHT}em` }}\n  >\n    {digit}\n  </span>\n));\n\nconst REGEX_WESTERN = /\\B(?=(\\d{3})+(?!\\d))/g;\nconst REGEX_INDIAN = /\\B(?=(\\d{2})+(?!\\d))/g;\n\nfunction formatInteger(whole: string, separator: string, grouping: Grouping) {\n  if (!separator) return whole;\n  if (grouping !== \"indian\") return whole.replace(REGEX_WESTERN, separator);\n\n  const head = whole.slice(0, -3);\n  if (!head) return whole;\n  return `${head.replace(REGEX_INDIAN, separator)}${separator}${whole.slice(-3)}`;\n}\n\ninterface Measurement {\n  amount: number;\n  scaled: number;\n  places: number;\n  duration: number;\n  width: number;\n}\n\nfunction computeMetrics(\n  value: number,\n  decimals: number,\n  padStart: number,\n  duration: number,\n): Measurement {\n  const amount = Number.isFinite(value) ? value : 0;\n  const places = Math.min(15, Math.max(0, Math.trunc(decimals)));\n  const pad = Math.min(24, Math.max(1, Math.trunc(padStart)));\n  const scaled = Math.min(\n    Number.MAX_SAFE_INTEGER,\n    Math.round(Math.abs(amount) * 10 ** places),\n  );\n\n  return {\n    amount,\n    scaled,\n    places,\n    duration: Math.min(60, Math.max(0.01, duration)),\n    width: Math.max(String(scaled).length, places + pad),\n  };\n}\n\nfunction stringifyValue(\n  metrics: Measurement,\n  separator: string,\n  decimalSeparator: string,\n  grouping: Grouping,\n) {\n  const raw = String(metrics.scaled).padStart(metrics.width, \"0\");\n  const whole = formatInteger(\n    raw.slice(0, raw.length - metrics.places) || \"0\",\n    separator,\n    grouping,\n  );\n  return metrics.places\n    ? `${whole}${decimalSeparator}${raw.slice(raw.length - metrics.places)}`\n    : whole;\n}\n\ntype CounterCell =\n  | { type: \"digit\"; key: number; digit: number }\n  | { type: \"mark\"; key: string; char: string };\n\nfunction buildCells(formatted: string, width: number): CounterCell[] {\n  const result: CounterCell[] = [];\n  let place = 0;\n  let markIndex = 0;\n\n  for (const char of formatted) {\n    if (char >= \"0\" && char <= \"9\") {\n      markIndex = 0;\n      result.push({\n        type: \"digit\",\n        key: width - place++,\n        digit: Number(char),\n      });\n    } else {\n      result.push({\n        type: \"mark\",\n        key: `mark-${width - place}-${markIndex++}`,\n        char,\n      });\n    }\n  }\n\n  return result;\n}\n\nfunction useContinuousWheel(\n  initialDigit: number,\n  targetDigit: number,\n  direction: number,\n  duration: number,\n  reduced: boolean,\n) {\n  const pos = useMotionValue(BAND + initialDigit);\n  const headingRef = useRef(direction);\n\n  useEffect(() => {\n    headingRef.current = direction;\n  }, [direction]);\n\n  useEffect(() => {\n    if (reduced) {\n      pos.set(BAND + targetDigit);\n      return;\n    }\n\n    const current = pos.get();\n    const currentFace = ((Math.round(current) % 10) + 10) % 10;\n    let diff = targetDigit - currentFace;\n\n    if (headingRef.current > 0 && diff <= 0) diff += 10;\n    if (headingRef.current < 0 && diff >= 0) diff -= 10;\n\n    const nextTarget = current + diff;\n    const animation = animate(pos, nextTarget, createSpring(duration));\n\n    return () => {\n      animation.stop();\n      pos.set(BAND + targetDigit);\n    };\n  }, [targetDigit, duration, reduced, pos]);\n\n  return useTransform(pos, (p) => `${(-p * 100) / WHEEL.length}%`);\n}\n\ninterface DigitSlotProps {\n  reduced: boolean;\n  dep: number;\n  transition: Transition;\n  filterId?: string;\n}\n\nconst DigitColumn = memo(function DigitColumn({\n  digit,\n  from,\n  direction,\n  duration,\n  slot,\n  ref,\n}: {\n  digit: number;\n  from: number;\n  direction: number;\n  duration: number;\n  slot: DigitSlotProps;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const y = useContinuousWheel(from, digit, direction, duration, slot.reduced);\n\n  return (\n    <motion.span\n      ref={ref}\n      data-slot=\"animated-counter-digit\"\n      layout={!slot.reduced}\n      layoutDependency={slot.dep}\n      transition={slot.transition}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{\n        opacity: 0,\n        transition: slot.reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n      }}\n      className=\"relative inline-grid overflow-hidden select-none\"\n      style={{\n        height: `${LINE_HEIGHT}em`,\n        lineHeight: LINE_HEIGHT,\n        maskImage: MASK_GRADIENT,\n        WebkitMaskImage: MASK_GRADIENT,\n        filter: slot.filterId ? `url(#${slot.filterId})` : undefined,\n      }}\n    >\n      {SIZER_NODES}\n      <motion.span style={{ y }} className=\"absolute inset-x-0 top-0\">\n        {STACK_NODES}\n      </motion.span>\n    </motion.span>\n  );\n});\n\nfunction GooeyDef({ id }: { id: string }) {\n  return (\n    <svg\n      className=\"absolute w-0 h-0 pointer-events-none opacity-0 overflow-hidden\"\n      aria-hidden=\"true\"\n      tabIndex={-1}\n    >\n      <defs>\n        <filter id={id} x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"0.65\" result=\"blur\" />\n          <feColorMatrix\n            in=\"blur\"\n            mode=\"matrix\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 16 -6\"\n            result=\"goo\"\n          />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n        </filter>\n      </defs>\n    </svg>\n  );\n}\n\nexport function AnimatedCounter({\n  value,\n  decimals = 0,\n  duration = 0.6,\n  padStart = 1,\n  separator = \",\",\n  decimalSeparator = \".\",\n  grouping = \"western\",\n  prefix,\n  suffix,\n  gooey = true,\n  className,\n  ...props\n}: AnimatedCounterProps) {\n  const generatedId = useId().replace(/:/g, \"_\");\n  const filterId = `gooey_${generatedId}`;\n  const reduced = useReducedMotion() ?? false;\n\n  const metrics = computeMetrics(value, decimals, padStart, duration);\n  const formatted = stringifyValue(metrics, separator, decimalSeparator, grouping);\n  const cells = buildCells(formatted, metrics.width);\n  const isNegative = metrics.amount < 0 && metrics.scaled > 0;\n\n  const [previous, setPrevious] = useState(metrics.amount);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== metrics.amount) {\n    setDirection(metrics.amount >= previous ? 1 : -1);\n    setPrevious(metrics.amount);\n  }\n\n  const [seedFaces] = useState(() => {\n    const initial: Record<number, number> = {};\n    for (const cell of cells) {\n      if (cell.type === \"digit\") initial[cell.key] = cell.digit;\n    }\n    return initial;\n  });\n\n  const springTransition = useMemo<Transition>(\n    () => (reduced ? INSTANT_TRANSITION : createSpring(metrics.duration)),\n    [reduced, metrics.duration],\n  );\n\n  const slotProps: DigitSlotProps = {\n    reduced,\n    dep: formatted.length,\n    transition: springTransition,\n    filterId: gooey && !reduced ? filterId : undefined,\n  };\n\n  return (\n    <span\n      data-slot=\"animated-counter\"\n      className={cn(\"inline-flex items-center tabular-nums relative\", className)}\n      {...props}\n    >\n      {gooey && !reduced && <GooeyDef id={filterId} />}\n\n      {prefix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {prefix}\n        </motion.span>\n      )}\n\n      <span className=\"sr-only\">\n        {isNegative ? \"-\" : \"\"}\n        {formatted}\n      </span>\n\n      <span aria-hidden className=\"inline-flex select-none items-center\">\n        {isNegative && (\n          <motion.span\n            layout={!reduced}\n            layoutDependency={formatted.length}\n            transition={springTransition}\n            className=\"inline-block\"\n          >\n            -\n          </motion.span>\n        )}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {cells.map((cell) =>\n            cell.type === \"digit\" ? (\n              <DigitColumn\n                key={cell.key}\n                digit={cell.digit}\n                from={seedFaces[cell.key] ?? cell.digit}\n                direction={direction}\n                duration={metrics.duration}\n                slot={slotProps}\n              />\n            ) : (\n              <motion.span\n                key={cell.key}\n                data-slot=\"animated-counter-mark\"\n                layout={!reduced}\n                layoutDependency={formatted.length}\n                transition={springTransition}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{\n                  opacity: 0,\n                  transition: reduced ? INSTANT_TRANSITION : LEAVE_TRANSITION,\n                }}\n                className=\"inline-block\"\n              >\n                {cell.char}\n              </motion.span>\n            ),\n          )}\n        </AnimatePresence>\n      </span>\n\n      {suffix != null && (\n        <motion.span\n          layout={!reduced}\n          layoutDependency={formatted.length}\n          transition={springTransition}\n          className=\"inline-block\"\n        >\n          {suffix}\n        </motion.span>\n      )}\n    </span>\n  );\n}\n\nexport default AnimatedCounter;\n",
      "type": "registry:ui",
      "target": "@ui/animated-counter.tsx"
    }
  ]
}
