{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-trail-reveal",
  "title": "Cursor Trail Reveal",
  "description": "Desktop cursor trail that reveals staggered image strips with clip-path wipes as the pointer moves.",
  "dependencies": ["class-variance-authority"],
  "registryDependencies": [
    "utils",
    "@soralabs/hooks-use-prefers-reduced-motion"
  ],
  "files": [
    {
      "path": "registry/primitives/effects/cursor-trail-reveal/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { type ComponentPropsWithoutRef, useEffect, useRef } from \"react\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nconst MASK_LAYER_COUNT = 10;\nconst DEFAULT_IMAGE_SIZE = 175;\n\nconst cursorTrailRevealVariants = cva(\n  \"pointer-events-none absolute inset-0 z-[2] overflow-hidden\",\n  {\n    variants: {\n      layout: {\n        default: \"\",\n        fill: \"size-full\",\n      },\n    },\n    defaultVariants: {\n      layout: \"default\",\n    },\n  }\n);\n\ninterface TrailConfig {\n  easing: string;\n  imageLifespan: number;\n  inDuration: number;\n  mouseThreshold: number;\n  outDuration: number;\n  slideDuration: number;\n  slideEasing: string;\n  staggerIn: number;\n  staggerOut: number;\n}\n\ninterface TrailImageEntry {\n  element: HTMLDivElement;\n  imageLayers: HTMLDivElement[];\n  maskLayers: HTMLDivElement[];\n  removeTime: number;\n}\n\nconst MathUtils = {\n  lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b,\n  distance: (x1: number, y1: number, x2: number, y2: number) =>\n    Math.hypot(x2 - x1, y2 - y1),\n};\n\nexport interface CursorTrailRevealProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\">,\n    VariantProps<typeof cursorTrailRevealVariants> {\n  /** Viewport width above which the trail is active. */\n  desktopBreakpoint?: number;\n  /** Trail thumbnail size in pixels. */\n  imageSize?: number;\n  /** Image URLs cycled as the cursor moves (desktop only). */\n  images: readonly string[];\n  /** Background color behind each strip during the clip-path reveal. */\n  maskColor?: string;\n  /** Minimum pointer travel before spawning the next image. */\n  mouseThreshold?: number;\n}\n\nfunction CursorTrailReveal({\n  className,\n  desktopBreakpoint = 1000,\n  imageSize = DEFAULT_IMAGE_SIZE,\n  images,\n  layout = \"default\",\n  maskColor = \"var(--background, #1a1a1a)\",\n  mouseThreshold = 150,\n  ...props\n}: CursorTrailRevealProps) {\n  const trailContainerRef = useRef<HTMLDivElement>(null);\n  const animationStateRef = useRef<number | null>(null);\n  const trailRef = useRef<TrailImageEntry[]>([]);\n  const timeoutIdsRef = useRef<number[]>([]);\n  const currentImageIndexRef = useRef(0);\n  const mousePosRef = useRef({ x: 0, y: 0 });\n  const lastMousePosRef = useRef({ x: 0, y: 0 });\n  const interpolatedMousePosRef = useRef({ x: 0, y: 0 });\n  const isDesktopRef = useRef(false);\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const configRef = useRef<TrailConfig>({\n    imageLifespan: 1000,\n    mouseThreshold,\n    inDuration: 750,\n    outDuration: 1000,\n    staggerIn: 100,\n    staggerOut: 25,\n    slideDuration: 1000,\n    slideEasing: \"cubic-bezier(0.25, 0.46, 0.45, 0.94)\",\n    easing: \"cubic-bezier(0.87, 0, 0.13, 1)\",\n  });\n\n  useEffect(() => {\n    configRef.current.mouseThreshold = mouseThreshold;\n  }, [mouseThreshold]);\n\n  useEffect(() => {\n    if (images.length === 0) {\n      return;\n    }\n\n    for (const src of images) {\n      const img = new Image();\n      img.src = src;\n    }\n  }, [images]);\n\n  useEffect(() => {\n    const trailContainer = trailContainerRef.current;\n    if (!trailContainer || images.length === 0) {\n      return;\n    }\n\n    const clearScheduledTimeouts = () => {\n      for (const id of timeoutIdsRef.current) {\n        clearTimeout(id);\n      }\n      timeoutIdsRef.current.length = 0;\n    };\n\n    const scheduleTimeout = (callback: () => void, delay: number) => {\n      const id = window.setTimeout(() => {\n        const index = timeoutIdsRef.current.indexOf(id);\n        if (index !== -1) {\n          timeoutIdsRef.current.splice(index, 1);\n        }\n        callback();\n      }, delay);\n      timeoutIdsRef.current.push(id);\n    };\n\n    const halfImageSize = imageSize / 2;\n    const trailImageCount = images.length;\n    const config = configRef.current;\n\n    isDesktopRef.current = window.innerWidth > desktopBreakpoint;\n\n    const getMouseDistance = () =>\n      MathUtils.distance(\n        mousePosRef.current.x,\n        mousePosRef.current.y,\n        lastMousePosRef.current.x,\n        lastMousePosRef.current.y\n      );\n\n    const isInTrailContainer = (x: number, y: number) => {\n      const rect = trailContainer.getBoundingClientRect();\n      return (\n        x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom\n      );\n    };\n\n    const createTrailImage = () => {\n      const imgContainer = document.createElement(\"div\");\n      imgContainer.className = \"pointer-events-none absolute overflow-hidden\";\n      imgContainer.style.width = `${imageSize}px`;\n      imgContainer.style.height = `${imageSize}px`;\n\n      const imgSrc = images[currentImageIndexRef.current] ?? images[0] ?? \"\";\n      currentImageIndexRef.current =\n        (currentImageIndexRef.current + 1) % trailImageCount;\n\n      const rect = trailContainer.getBoundingClientRect();\n      const startX =\n        interpolatedMousePosRef.current.x - rect.left - halfImageSize;\n      const startY =\n        interpolatedMousePosRef.current.y - rect.top - halfImageSize;\n      const targetX = mousePosRef.current.x - rect.left - halfImageSize;\n      const targetY = mousePosRef.current.y - rect.top - halfImageSize;\n\n      imgContainer.style.left = \"0px\";\n      imgContainer.style.top = \"0px\";\n      imgContainer.style.willChange = \"transform\";\n      imgContainer.style.transform = `translate3d(${startX}px, ${startY}px, 0)`;\n      imgContainer.style.transition = `transform ${config.slideDuration}ms ${config.slideEasing}`;\n\n      const maskLayers: HTMLDivElement[] = [];\n      const imageLayers: HTMLDivElement[] = [];\n\n      for (let i = 0; i < MASK_LAYER_COUNT; i++) {\n        const layer = document.createElement(\"div\");\n        layer.className = \"absolute inset-0 will-change-[clip-path]\";\n        layer.style.backgroundColor = maskColor;\n\n        const imageLayer = document.createElement(\"div\");\n        imageLayer.className = \"absolute inset-0 bg-cover bg-center\";\n        imageLayer.style.backgroundImage = `url(${imgSrc})`;\n\n        const stripStart = i * 10;\n        const stripEnd = (i + 1) * 10;\n\n        layer.style.clipPath = `polygon(50% ${stripStart}%, 50% ${stripStart}%, 50% ${stripEnd}%, 50% ${stripEnd}%)`;\n        layer.style.transition = `clip-path ${config.inDuration}ms ${config.easing}`;\n        layer.style.transform = \"translateZ(0)\";\n        layer.style.backfaceVisibility = \"hidden\";\n\n        layer.appendChild(imageLayer);\n        imgContainer.appendChild(layer);\n        maskLayers.push(layer);\n        imageLayers.push(imageLayer);\n      }\n\n      trailContainer.appendChild(imgContainer);\n\n      requestAnimationFrame(() => {\n        imgContainer.style.transform = `translate3d(${targetX}px, ${targetY}px, 0)`;\n\n        for (const [i, layer] of maskLayers.entries()) {\n          const stripStart = i * 10;\n          const stripEnd = (i + 1) * 10;\n          const distanceFromMiddle = Math.abs(i - 4.5);\n          const delay = distanceFromMiddle * config.staggerIn;\n\n          scheduleTimeout(() => {\n            layer.style.clipPath = `polygon(0% ${stripStart}%, 100% ${stripStart}%, 100% ${stripEnd}%, 0% ${stripEnd}%)`;\n          }, delay);\n        }\n      });\n\n      trailRef.current.push({\n        element: imgContainer,\n        maskLayers,\n        imageLayers,\n        removeTime: Date.now() + config.imageLifespan,\n      });\n    };\n\n    const removeOldImages = () => {\n      const now = Date.now();\n      if (trailRef.current.length === 0) {\n        return;\n      }\n\n      const oldestImage = trailRef.current[0];\n      if (!oldestImage || now < oldestImage.removeTime) {\n        return;\n      }\n\n      const imgToRemove = trailRef.current.shift();\n      if (!imgToRemove) {\n        return;\n      }\n\n      for (const [i, layer] of imgToRemove.maskLayers.entries()) {\n        const stripStart = i * 10;\n        const stripEnd = (i + 1) * 10;\n        const distanceFromEdge = 4.5 - Math.abs(i - 4.5);\n        const delay = distanceFromEdge * config.staggerOut;\n\n        layer.style.transition = `clip-path ${config.outDuration}ms ${config.easing}`;\n\n        scheduleTimeout(() => {\n          layer.style.clipPath = `polygon(50% ${stripStart}%, 50% ${stripStart}%, 50% ${stripEnd}%, 50% ${stripEnd}%)`;\n        }, delay);\n      }\n\n      for (const imageLayer of imgToRemove.imageLayers) {\n        imageLayer.style.transition = `opacity ${config.outDuration}ms ${config.easing}`;\n        imageLayer.style.opacity = \"0.25\";\n      }\n\n      scheduleTimeout(() => {\n        imgToRemove.element.remove();\n      }, config.outDuration + 112);\n    };\n\n    const render = () => {\n      if (!isDesktopRef.current) {\n        return;\n      }\n\n      const distance = getMouseDistance();\n\n      interpolatedMousePosRef.current.x = MathUtils.lerp(\n        interpolatedMousePosRef.current.x || mousePosRef.current.x,\n        mousePosRef.current.x,\n        0.1\n      );\n      interpolatedMousePosRef.current.y = MathUtils.lerp(\n        interpolatedMousePosRef.current.y || mousePosRef.current.y,\n        mousePosRef.current.y,\n        0.1\n      );\n\n      if (\n        distance > config.mouseThreshold &&\n        isInTrailContainer(mousePosRef.current.x, mousePosRef.current.y)\n      ) {\n        createTrailImage();\n        lastMousePosRef.current = { ...mousePosRef.current };\n      }\n\n      removeOldImages();\n      animationStateRef.current = requestAnimationFrame(render);\n    };\n\n    const startAnimation = (): (() => void) | null => {\n      if (!isDesktopRef.current || prefersReducedMotion) {\n        return null;\n      }\n\n      const handleMouseMove = (event: MouseEvent) => {\n        mousePosRef.current = { x: event.clientX, y: event.clientY };\n      };\n\n      document.addEventListener(\"mousemove\", handleMouseMove);\n      animationStateRef.current = requestAnimationFrame(render);\n\n      return () => {\n        document.removeEventListener(\"mousemove\", handleMouseMove);\n      };\n    };\n\n    const stopAnimation = () => {\n      if (animationStateRef.current !== null) {\n        cancelAnimationFrame(animationStateRef.current);\n        animationStateRef.current = null;\n      }\n\n      clearScheduledTimeouts();\n\n      for (const item of trailRef.current) {\n        item.element.remove();\n      }\n      trailRef.current.length = 0;\n    };\n\n    let cleanUpMouseListener: (() => void) | null = null;\n\n    const handleResize = () => {\n      const wasDesktop = isDesktopRef.current;\n      isDesktopRef.current = window.innerWidth > desktopBreakpoint;\n\n      if (prefersReducedMotion) {\n        return;\n      }\n\n      if (isDesktopRef.current && !wasDesktop) {\n        cleanUpMouseListener = startAnimation();\n      } else if (!isDesktopRef.current && wasDesktop) {\n        stopAnimation();\n        cleanUpMouseListener?.();\n        cleanUpMouseListener = null;\n      }\n    };\n\n    window.addEventListener(\"resize\", handleResize);\n\n    if (!prefersReducedMotion && isDesktopRef.current) {\n      cleanUpMouseListener = startAnimation();\n    }\n\n    return () => {\n      stopAnimation();\n      cleanUpMouseListener?.();\n      window.removeEventListener(\"resize\", handleResize);\n    };\n  }, [desktopBreakpoint, imageSize, images, maskColor, prefersReducedMotion]);\n\n  return (\n    <div\n      className={cn(cursorTrailRevealVariants({ layout, className }))}\n      ref={trailContainerRef}\n      {...props}\n    />\n  );\n}\n\nexport { CursorTrailReveal, cursorTrailRevealVariants };\n",
      "type": "registry:ui",
      "target": "components/sora-ui/effects/cursor-trail-reveal.tsx"
    }
  ],
  "type": "registry:ui"
}
