{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-trail-smear",
  "description": "Your cursor drags a liquid streak across the image that trails and fades.",
  "dependencies": [
    "lil-gui",
    "three"
  ],
  "registryDependencies": [
    "https://www.pixel-perfect.space/r/animated-texture.json"
  ],
  "files": [
    {
      "path": "registry/new-york/shaders/cursor-trail-smear.tsx",
      "content": "\"use client\";\n\n/**\n * Your cursor drags a liquid streak across the image that trails and fades.\n */\n\n\nimport { useEffect, useRef } from \"react\";\nimport * as THREE from \"three\";\nimport GUI from \"lil-gui\";\nimport { createAnimatedTexture } from \"./animated-texture\";\n\nconst MAX_TRAIL = 24;\n\nconst CursorTrailSmear = ({\n  image,\n  className,\n  dpr = 2,\n  controls = false,\n}: {\n  image: string;\n  className?: string;\n  dpr?: number;\n  controls?: boolean;\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const config = { strength: 0.5, tightness: 90, decay: 1.6 };\n\n    const renderer = new THREE.WebGLRenderer({\n      antialias: true,\n      powerPreference: \"low-power\",\n    });\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, dpr));\n    container.appendChild(renderer.domElement);\n    const canvas = renderer.domElement;\n    const onContextLost = (e: Event) => e.preventDefault();\n    canvas.addEventListener(\"webglcontextlost\", onContextLost);\n\n    const camera = new THREE.Camera();\n    const scene = new THREE.Scene();\n    const geometry = new THREE.PlaneGeometry(2, 2);\n\n    const animated = createAnimatedTexture(image);\n    const trailVecs = Array.from(\n      { length: MAX_TRAIL },\n      () => new THREE.Vector4(),\n    );\n    const weights = new Float32Array(MAX_TRAIL);\n\n    const uniforms = {\n      uTexture: { value: animated.texture },\n      uResolution: { value: new THREE.Vector2() },\n      uImageResolution: { value: new THREE.Vector2(1, 1) },\n      uTrail: { value: trailVecs }, // xy = uv, zw = velocity\n      uWeights: { value: weights },\n      uCount: { value: 0 },\n      uStrength: { value: config.strength },\n      uTightness: { value: config.tightness },\n    };\n\n    const material = new THREE.ShaderMaterial({\n      uniforms,\n      vertexShader: /* glsl */ `\n        varying vec2 vUv;\n        void main() {\n          vUv = uv;\n          gl_Position = vec4(position.xy, 0.0, 1.0);\n        }\n      `,\n      fragmentShader: /* glsl */ `\n        precision highp float;\n        uniform sampler2D uTexture;\n        uniform vec2 uResolution;\n        uniform vec2 uImageResolution;\n        uniform vec4 uTrail[${MAX_TRAIL}];\n        uniform float uWeights[${MAX_TRAIL}];\n        uniform int uCount;\n        uniform float uStrength;\n        uniform float uTightness;\n        varying vec2 vUv;\n\n        vec2 coverUV(vec2 uv) {\n          float sa = uResolution.x / uResolution.y;\n          float ia = uImageResolution.x / uImageResolution.y;\n          vec2 s = vec2(1.0);\n          if (sa > ia) s.y = ia / sa; else s.x = sa / ia;\n          return (uv - 0.5) * s + 0.5;\n        }\n\n        void main() {\n          float aspect = uResolution.x / uResolution.y;\n          vec2 disp = vec2(0.0);\n          for (int i = 0; i < ${MAX_TRAIL}; i++) {\n            if (i >= uCount) break;\n            vec4 t = uTrail[i];\n            vec2 d = vUv - t.xy;\n            d.x *= aspect;\n            float g = exp(-dot(d, d) * uTightness); // soft blob around the sample\n            disp += t.zw * g * uWeights[i];\n          }\n          vec3 col = texture2D(uTexture, coverUV(vUv + disp * uStrength)).rgb;\n          gl_FragColor = vec4(col, 1.0);\n        }\n      `,\n    });\n    const mesh = new THREE.Mesh(geometry, material);\n    scene.add(mesh);\n\n    const trail: {\n      x: number;\n      y: number;\n      vx: number;\n      vy: number;\n      start: number;\n    }[] = [];\n    let shaderTime = 0;\n    let px = -1;\n    let py = -1;\n    const LIFE = 1.2; // seconds a sample stays alive\n\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      if (!r.width || !r.height) return;\n      const x = (e.clientX - r.left) / r.width;\n      const y = 1 - (e.clientY - r.top) / r.height;\n      if (px >= 0) {\n        const vx = x - px;\n        const vy = y - py;\n        if (vx * vx + vy * vy > 1e-6) {\n          trail.push({ x, y, vx, vy, start: shaderTime });\n          if (trail.length > MAX_TRAIL) trail.shift();\n        }\n      }\n      px = x;\n      py = y;\n    };\n    const onLeave = () => {\n      px = -1;\n      py = -1;\n    };\n    canvas.addEventListener(\"pointermove\", onMove);\n    canvas.addEventListener(\"pointerleave\", onLeave);\n\n    const render = () => {\n      const img = animated.texture.image as { width: number; height: number };\n      if (img && img.width > 1) {\n        uniforms.uImageResolution.value.set(img.width, img.height);\n      }\n      for (let i = trail.length - 1; i >= 0; i--) {\n        if (shaderTime - trail[i].start > LIFE) trail.splice(i, 1);\n      }\n      const n = Math.min(trail.length, MAX_TRAIL);\n      for (let i = 0; i < n; i++) {\n        const s = trail[trail.length - 1 - i];\n        trailVecs[i].set(s.x, s.y, s.vx, s.vy);\n        const age = (shaderTime - s.start) / LIFE; // 0 → 1\n        weights[i] = Math.exp(-age * config.decay) * (1 - age);\n      }\n      uniforms.uCount.value = n;\n      renderer.render(scene, camera);\n    };\n\n    let last = performance.now();\n    const tick = () => {\n      const now = performance.now();\n      const dt = Math.min((now - last) / 1000, 0.05);\n      last = now;\n      shaderTime += dt;\n      animated.update(shaderTime * 1000);\n      render();\n    };\n\n    const resize = () => {\n      const w = container.clientWidth;\n      const h = container.clientHeight;\n      if (!w || !h) return;\n      renderer.setSize(w, h);\n      uniforms.uResolution.value.set(canvas.width, canvas.height);\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(container);\n\n    let visible = true;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        visible = e.isIntersecting;\n        last = performance.now();\n      },\n      { rootMargin: \"150px\", threshold: 0 },\n    );\n    io.observe(container);\n\n    let raf = 0;\n    const animate = () => {\n      raf = requestAnimationFrame(animate);\n      if (visible) tick();\n    };\n    animate();\n\n    let gui: GUI | null = null;\n    if (controls) {\n      gui = new GUI();\n      gui.domElement.style.zIndex = \"10000\";\n      gui\n        .add(config, \"strength\", 0, 1.2, 0.02)\n        .name(\"strength\")\n        .onChange((v: number) => {\n          uniforms.uStrength.value = v;\n        });\n      gui\n        .add(config, \"tightness\", 30, 200, 5)\n        .name(\"focus\")\n        .onChange((v: number) => {\n          uniforms.uTightness.value = v;\n        });\n      gui.add(config, \"decay\", 0.5, 4, 0.1).name(\"decay\");\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      gui?.destroy();\n      ro.disconnect();\n      io.disconnect();\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost);\n      canvas.removeEventListener(\"pointermove\", onMove);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n      if (canvas.parentNode === container) container.removeChild(canvas);\n      animated.dispose();\n      geometry.dispose();\n      material.dispose();\n      renderer.forceContextLoss();\n      renderer.dispose();\n    };\n  }, [image, dpr, controls]);\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden\n      className={className}\n      style={{ overflow: \"hidden\" }}\n    />\n  );\n};\n\nexport default CursorTrailSmear;\n",
      "type": "registry:component",
      "target": "components/pixel-perfect/cursor-trail-smear.tsx"
    }
  ],
  "type": "registry:block"
}