{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "grid-image-loader",
  "name": "Grid Image Loader",
  "title": "Grid Image Loader",
  "type": "registry:ui",
  "description": "A dark grid loader with smooth cycling phrases, images on cursor hover, and an upward content reveal.",
  "summary": "Move across the grid to reveal photographs inside each hovered cell. Centered phrases change with a scoped GSAP sequence before the loading curtain lifts. Customize phrases, images, grid dimensions, timing, colors and progress.",
  "category": "loaders",
  "tags": [
    "loader",
    "grid",
    "images",
    "cursor",
    "hover",
    "phrases",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "cursor-trail-loader",
    "animated-counter"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-03",
  "updatedDate": "2026-10-03",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Images align precisely to hovered grid cells",
    "Smooth centered phrase transitions synchronized to loading",
    "Bounded reusable image pool with fading cards",
    "Shared cursor-loader lifecycle, progress, pause and replay",
    "Upward curtain reveals children after loading",
    "Reduced-motion fallback and touch-safe loading"
  ],
  "anatomy": [
    "<GridImageLoader> configured wrapper around the shared cursor-loader engine",
    "Responsive decorative grid",
    "Fixed cell image cards with an entrance and fade timeline",
    "Centered phrase mask and transition",
    "Loading curtain and inert content layer"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "Each hover card reuses an entrance, hold and fade timeline. Grid coordinates come from container measurements, and resize observation keeps cards aligned. One master timeline synchronizes loading phases and curtain motion. Phrase timelines, pointer listeners and observers are cleaned up on unmount.",
    "parameters": [
      {
        "label": "Loading",
        "value": "6s"
      },
      {
        "label": "Grid",
        "value": "4 × 4 cells"
      },
      {
        "label": "Phrase transition",
        "value": "0.12s out / 0.24s in"
      },
      {
        "label": "Reveal",
        "value": "0.95s · power3.inOut"
      }
    ]
  },
  "accessibility": {
    "role": "progressbar",
    "aria": "Progress is labeled; grid, phrases and hover images are decorative. Revealed content stays inert and hidden from assistive technology until completion.",
    "reducedMotion": "Image and phrase animations are disabled. Automatic loading reveals immediately; controlled loading waits for 100%."
  },
  "guidelines": {
    "recommended": [
      "Image-led landing pages and portfolio introductions",
      "Brief loading sequences with customizable editorial phrases",
      "Use real controlled progress for production loading"
    ],
    "bestPractices": [
      "Provide descriptive alt text for revealed content through children",
      "Use landscape hover images for wide cells",
      "Hover imagery requires a fine pointer; loading still completes on touch devices",
      "Use replayKey to replay and paused to suspend all motion",
      "Keep the phase duration long enough for phrases to be read"
    ]
  },
  "props": [
    {
      "name": "images",
      "type": "string[]",
      "description": "Hover image URLs. Loaded images cycle as the pointer enters new grid cells; 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": "6"
    },
    {
      "name": "revealDuration",
      "type": "number",
      "description": "Duration of the upward curtain reveal in seconds.",
      "defaultValue": "0.95"
    },
    {
      "name": "trailLifetime",
      "type": "number",
      "description": "Image hold before fading, in seconds.",
      "defaultValue": "0.65"
    },
    {
      "name": "borderRadius",
      "type": "number",
      "description": "Image corner radius in pixels.",
      "defaultValue": "0"
    },
    {
      "name": "background",
      "type": "string",
      "description": "Loading curtain background color.",
      "defaultValue": "\"#191919\""
    },
    {
      "name": "foreground",
      "type": "string",
      "description": "Loading counter color.",
      "defaultValue": "\"#f0efe9\""
    },
    {
      "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": "messages",
      "type": "readonly string[]",
      "description": "Centered loading phrases, shown in sequence as loading progresses. Up to 12 nonempty phrases are supported.",
      "defaultValue": "GRID_IMAGE_LOADER_MESSAGES"
    },
    {
      "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.07"
    }
  ],
  "usage": "import { GridImageLoader } from \"@/components/ui/grid-image-loader\";\n\nconst images = [\"/images/grid-1.jpg\", \"/images/grid-2.jpg\", \"/images/grid-3.jpg\"];\n\nexport function GridImageLoaderDemo() {\n  return (\n    <GridImageLoader images={images} className=\"h-[480px]\">\n      <h1>Ready</h1>\n    </GridImageLoader>\n  );\n}",
  "files": [
    {
      "name": "grid-image-loader.tsx",
      "path": "registry/ui/grid-image-loader.tsx",
      "code": "\"use client\";\n\nimport {\n  CursorTrailLoader,\n  type CursorTrailLoaderProps,\n} from \"./cursor-trail-loader\";\n\nexport const GRID_IMAGE_LOADER_MESSAGES = [\n  \"MOVE FIRST\",\n  \"OWN THE PACE\",\n  \"CHASE DAYLIGHT\",\n  \"MOVE FIRST\",\n  \"READY?\",\n  \"LET'S GO\",\n] as const;\n\nexport type GridImageLoaderProps = Pick<\n  CursorTrailLoaderProps,\n  | \"images\"\n  | \"children\"\n  | \"progress\"\n  | \"duration\"\n  | \"revealDuration\"\n  | \"trailLifetime\"\n  | \"borderRadius\"\n  | \"background\"\n  | \"foreground\"\n  | \"paused\"\n  | \"replayKey\"\n  | \"label\"\n  | \"className\"\n  | \"onComplete\"\n  | \"messages\"\n  | \"gridColumns\"\n  | \"gridRows\"\n  | \"gridOpacity\"\n>;\n\nexport function GridImageLoader({\n  messages = GRID_IMAGE_LOADER_MESSAGES,\n  duration = 6,\n  revealDuration = 0.95,\n  trailLifetime = 0.65,\n  gridColumns = 4,\n  gridRows = 4,\n  gridOpacity = 0.07,\n  borderRadius = 0,\n  background = \"#191919\",\n  foreground = \"#f0efe9\",\n  ...props\n}: GridImageLoaderProps) {\n  return (\n    <CursorTrailLoader\n      {...props}\n      layout=\"grid\"\n      messages={messages}\n      duration={duration}\n      revealDuration={revealDuration}\n      trailLifetime={trailLifetime}\n      gridColumns={gridColumns}\n      gridRows={gridRows}\n      gridOpacity={gridOpacity}\n      borderRadius={borderRadius}\n      background={background}\n      foreground={foreground}\n      trailLength={12}\n      rotation={0}\n    />\n  );\n}\n\nexport default GridImageLoader;\n",
      "content": "\"use client\";\n\nimport {\n  CursorTrailLoader,\n  type CursorTrailLoaderProps,\n} from \"./cursor-trail-loader\";\n\nexport const GRID_IMAGE_LOADER_MESSAGES = [\n  \"MOVE FIRST\",\n  \"OWN THE PACE\",\n  \"CHASE DAYLIGHT\",\n  \"MOVE FIRST\",\n  \"READY?\",\n  \"LET'S GO\",\n] as const;\n\nexport type GridImageLoaderProps = Pick<\n  CursorTrailLoaderProps,\n  | \"images\"\n  | \"children\"\n  | \"progress\"\n  | \"duration\"\n  | \"revealDuration\"\n  | \"trailLifetime\"\n  | \"borderRadius\"\n  | \"background\"\n  | \"foreground\"\n  | \"paused\"\n  | \"replayKey\"\n  | \"label\"\n  | \"className\"\n  | \"onComplete\"\n  | \"messages\"\n  | \"gridColumns\"\n  | \"gridRows\"\n  | \"gridOpacity\"\n>;\n\nexport function GridImageLoader({\n  messages = GRID_IMAGE_LOADER_MESSAGES,\n  duration = 6,\n  revealDuration = 0.95,\n  trailLifetime = 0.65,\n  gridColumns = 4,\n  gridRows = 4,\n  gridOpacity = 0.07,\n  borderRadius = 0,\n  background = \"#191919\",\n  foreground = \"#f0efe9\",\n  ...props\n}: GridImageLoaderProps) {\n  return (\n    <CursorTrailLoader\n      {...props}\n      layout=\"grid\"\n      messages={messages}\n      duration={duration}\n      revealDuration={revealDuration}\n      trailLifetime={trailLifetime}\n      gridColumns={gridColumns}\n      gridRows={gridRows}\n      gridOpacity={gridOpacity}\n      borderRadius={borderRadius}\n      background={background}\n      foreground={foreground}\n      trailLength={12}\n      rotation={0}\n    />\n  );\n}\n\nexport default GridImageLoader;\n",
      "type": "registry:ui",
      "target": "@ui/grid-image-loader.tsx"
    },
    {
      "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"
    }
  ]
}
