{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-particle-tunnel",
  "title": "Scroll Particle Tunnel",
  "description": "Scroll scrubs a camera through a monochrome point tunnel: velocity stretches dots into motion streaks, cursor drift adds parallax, and mono section labels at fixed depths snap into focus as you fly past.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/scroll-particle-tunnel/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// world-space constants\nconst DEPTH = 4000; // tunnel length in world units\nconst TRAVEL = 3600; // camera travel across the full scrub\nconst FOCAL = 600; // perspective focal length: scale = FOCAL / (z - camZ)\nconst R_MIN = 120; // cylinder shell inner radius\nconst R_MAX = 900; // cylinder shell outer radius\nconst NEAR_CLIP = 24; // cull points closer than this to the camera\n\n// streak-factor spring: k = 60, zeta = 0.8 (relaxes lines back to dots)\nconst SPRING_K = 60;\nconst SPRING_C = 2 * 0.8 * Math.sqrt(SPRING_K);\n\n// monochrome depth ramp: alpha rises 0.3 -> 1.0 with proximity, color pinned\n// to the live --foreground token so contrast against the page background is\n// correct in both themes (near = full foreground opacity, far = faded toward bg).\nfunction buildShades(rgb: { r: number; g: number; b: number }) {\n  return Array.from({ length: 24 }, (_, i) => {\n    const t = i / 23;\n    return `rgba(${rgb.r},${rgb.g},${rgb.b},${(0.3 + 0.7 * t).toFixed(3)})`;\n  });\n}\n\nfunction parseColor(raw: string): { r: number; g: number; b: number } | null {\n  const v = raw.trim();\n  if (v.startsWith(\"#\")) {\n    const hex = v.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0] + hex[0], 16);\n      const g = parseInt(hex[1] + hex[1], 16);\n      const b = parseInt(hex[2] + hex[2], 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    return null;\n  }\n  const m = v.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };\n  return null;\n}\n\nfunction readForegroundShades() {\n  const raw = getComputedStyle(document.documentElement).getPropertyValue(\"--foreground\");\n  return buildShades(parseColor(raw) ?? { r: 143, g: 143, b: 143 });\n}\n\n// deterministic PRNG so the field is identical across mounts and screenshots\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// seed points in a cylinder shell (uniform over the annulus area)\nfunction seedPoints(count: number) {\n  const rand = mulberry32(0x517cc1b7);\n  const px = new Float32Array(count);\n  const py = new Float32Array(count);\n  const pz = new Float32Array(count);\n  for (let i = 0; i < count; i++) {\n    const theta = rand() * Math.PI * 2;\n    const r = Math.sqrt(R_MIN * R_MIN + (R_MAX * R_MAX - R_MIN * R_MIN) * rand());\n    px[i] = Math.cos(theta) * r;\n    py[i] = Math.sin(theta) * r;\n    pz[i] = rand() * DEPTH;\n  }\n  return { px, py, pz };\n}\n\nfunction labelDepthAt(index: number, count: number) {\n  return count <= 1 ? DEPTH / 2 : 700 + (index * 2700) / (count - 1);\n}\n\n// Scroll scrubs a camera through a monochrome point tunnel. Scroll velocity\n// stretches dots into motion streaks, cursor drift adds parallax, and mono\n// labels sit at fixed world depths, snapping into focus as the camera passes.\n// Direct-DOM rAF loop — no React state on the hot path; sleeps when settled.\nexport function ParticleTunnelScrub({\n  labels = [\"01 SIGNAL\", \"02 NOISE\", \"03 FIELD\", \"04 VOID\"],\n  pointCount = 3000,\n  className = \"\",\n}: {\n  /** section labels pinned at fixed depths along the tunnel */\n  labels?: string[];\n  /** points seeded in the cylinder shell */\n  pointCount?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const [reduced, setReduced] = useState(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // full motion path\n  useEffect(() => {\n    if (reduced) return;\n    const section = sectionRef.current;\n    const canvas = canvasRef.current;\n    if (!section || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const { px, py, pz } = seedPoints(pointCount);\n    const prevX = new Float32Array(pointCount);\n    const prevY = new Float32Array(pointCount);\n    const dirX = new Float32Array(pointCount);\n    const dirY = new Float32Array(pointCount);\n    let hasPrev = false;\n    let shades = readForegroundShades();\n\n    const labelCount = labels.length;\n    const labelDepths = labels.map((_, i) => labelDepthAt(i, labelCount));\n\n    let w = 0;\n    let h = 0;\n    let p = 0; // scroll progress 0..1\n    let lastCamZ = 0;\n    let vel = 0; // EMA of camZ delta per 60fps frame\n    let streak = 0; // spring-smoothed streak length (px)\n    let streakV = 0;\n    let camX = 0;\n    let camY = 0;\n    let tX = 0; // parallax targets (world units)\n    let tY = 0;\n    let raf = 0;\n    let lastT = 0;\n    let dirty = true;\n\n    const frame = (now: number) => {\n      const dt = Math.min(0.05, lastT ? (now - lastT) / 1000 : 1 / 60);\n      lastT = now;\n      const frames = dt * 60;\n      dirty = false;\n\n      // camera scrub + scroll-velocity EMA (alpha = 0.12)\n      const camZ = p * TRAVEL;\n      const inst = frames > 0 ? (camZ - lastCamZ) / frames : 0;\n      lastCamZ = camZ;\n      vel += 0.12 * (inst - vel);\n\n      // streak spring toward clamp(|v| * 0.06, 0, 40) px\n      const target = Math.min(40, Math.abs(vel) * 0.06);\n      streakV += (SPRING_K * (target - streak) - SPRING_C * streakV) * dt;\n      streak += streakV * dt;\n      if (streak < 0) streak = 0;\n\n      // cursor parallax: lerp 0.08/frame toward pointer offset\n      const k = 1 - Math.pow(1 - 0.08, frames);\n      camX += (tX - camX) * k;\n      camY += (tY - camY) * k;\n\n      ctx.clearRect(0, 0, w, h);\n      const cx = w / 2;\n      const cy = h / 2;\n      const drawStreaks = streak > 0.5;\n\n      for (let i = 0; i < pointCount; i++) {\n        const dz = pz[i] - camZ;\n        if (dz < NEAR_CLIP) continue; // behind or grazing the camera\n        const s = FOCAL / dz;\n        const sx = cx + (px[i] - camX) * s;\n        const sy = cy + (py[i] - camY) * s;\n        if (sx < -60 || sx > w + 60 || sy < -60 || sy > h + 60) {\n          prevX[i] = sx;\n          prevY[i] = sy;\n          continue;\n        }\n        if (hasPrev) {\n          const mx = sx - prevX[i];\n          const my = sy - prevY[i];\n          const md = Math.hypot(mx, my);\n          if (md > 0.3) {\n            dirX[i] = mx / md;\n            dirY[i] = my / md;\n          }\n        }\n        prevX[i] = sx;\n        prevY[i] = sy;\n\n        const near = Math.min(1, 420 / dz);\n        const shade = shades[Math.min(23, (near * 23) | 0)] ?? \"#8f8f8f\";\n        const size = Math.min(3.2, Math.max(0.6, s * 1.5));\n        if (drawStreaks && (dirX[i] !== 0 || dirY[i] !== 0)) {\n          ctx.strokeStyle = shade;\n          ctx.lineWidth = size;\n          ctx.beginPath();\n          ctx.moveTo(sx - dirX[i] * streak, sy - dirY[i] * streak);\n          ctx.lineTo(sx, sy);\n          ctx.stroke();\n        } else {\n          ctx.fillStyle = shade;\n          ctx.fillRect(sx - size / 2, sy - size / 2, size, size);\n        }\n      }\n      hasPrev = true;\n\n      // labels: focus window +-300 units, fall to blur 6px over the next 900,\n      // scale runs 0.92 -> 1.08 through the pass so they fly by\n      for (let i = 0; i < labelCount; i++) {\n        const el = labelRefs.current[i];\n        if (!el) continue;\n        const d = (labelDepths[i] ?? 0) - camZ;\n        const ad = Math.abs(d);\n        let opacity = 0;\n        let blur = 6;\n        if (ad <= 300) {\n          opacity = 1;\n          blur = 0;\n        } else if (ad <= 1200) {\n          const t = (ad - 300) / 900;\n          opacity = 1 - t;\n          blur = 6 * t;\n        }\n        const scale = Math.min(1.08, Math.max(0.92, 1 - (d / 1200) * 0.08));\n        el.style.opacity = opacity.toFixed(3);\n        el.style.filter = blur > 0.02 ? `blur(${blur.toFixed(2)}px)` : \"none\";\n        el.style.transform = `scale(${scale.toFixed(4)})`;\n      }\n\n      // sleep when scroll, parallax, and the streak spring are all settled\n      const parallaxSettled = Math.abs(tX - camX) < 0.05 && Math.abs(tY - camY) < 0.05;\n      const springSettled = streak < 0.05 && Math.abs(streakV) < 0.05 && Math.abs(vel) < 0.05;\n      if (!dirty && parallaxSettled && springSettled) {\n        raf = 0;\n        lastT = 0;\n        return;\n      }\n      raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.lineCap = \"round\";\n      dirty = true;\n      wake();\n    };\n\n    const readScroll = () => {\n      const rect = section.getBoundingClientRect();\n      const track = rect.height - window.innerHeight;\n      p = track > 0 ? Math.min(1, Math.max(0, -rect.top / track)) : 0;\n      dirty = true;\n      wake();\n    };\n\n    const onPointer = (e: PointerEvent) => {\n      tX = (e.clientX - w / 2) * 0.04;\n      tY = (e.clientY - h / 2) * 0.04;\n      wake();\n    };\n\n    // re-derive the shade ramp from --foreground whenever the theme class\n    // flips on <html>, so light/dark contrast stays correct without a remount\n    const onThemeChange = () => {\n      shades = readForegroundShades();\n      dirty = true;\n      wake();\n    };\n    const themeObserver = new MutationObserver(onThemeChange);\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    resize();\n    readScroll();\n    lastCamZ = p * TRAVEL; // no phantom velocity on mount mid-page\n    wake();\n\n    window.addEventListener(\"scroll\", readScroll, { passive: true });\n    window.addEventListener(\"resize\", resize);\n    window.addEventListener(\"pointermove\", onPointer, { passive: true });\n    return () => {\n      cancelAnimationFrame(raf);\n      themeObserver.disconnect();\n      window.removeEventListener(\"scroll\", readScroll);\n      window.removeEventListener(\"resize\", resize);\n      window.removeEventListener(\"pointermove\", onPointer);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [reduced, pointCount, labels.length]);\n\n  // reduced motion: one static starfield frame, no loop\n  useEffect(() => {\n    if (!reduced) return;\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const { px, py, pz } = seedPoints(pointCount);\n    let shades = readForegroundShades();\n    const draw = () => {\n      const rect = canvas.getBoundingClientRect();\n      const w = rect.width;\n      const h = rect.height;\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      const camZ = DEPTH * 0.3;\n      for (let i = 0; i < pointCount; i++) {\n        const dz = pz[i] - camZ;\n        if (dz < NEAR_CLIP) continue;\n        const s = FOCAL / dz;\n        const sx = w / 2 + px[i] * s;\n        const sy = h / 2 + py[i] * s;\n        if (sx < -10 || sx > w + 10 || sy < -10 || sy > h + 10) continue;\n        const near = Math.min(1, 420 / dz);\n        ctx.fillStyle = shades[Math.min(23, (near * 23) | 0)] ?? \"#8f8f8f\";\n        const size = Math.min(3.2, Math.max(0.6, s * 1.5));\n        ctx.fillRect(sx - size / 2, sy - size / 2, size, size);\n      }\n    };\n    draw();\n    const onThemeChange = () => {\n      shades = readForegroundShades();\n      draw();\n    };\n    const themeObserver = new MutationObserver(onThemeChange);\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n    window.addEventListener(\"resize\", draw);\n    return () => {\n      themeObserver.disconnect();\n      window.removeEventListener(\"resize\", draw);\n    };\n  }, [reduced, pointCount]);\n\n  // reduced motion: labels fade in 200ms via IntersectionObserver\n  useEffect(() => {\n    if (!reduced) return;\n    const els = labelRefs.current\n      .slice(0, labels.length)\n      .filter((el): el is HTMLSpanElement => el !== null);\n    const io = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          (entry.target as HTMLElement).style.opacity = entry.isIntersecting ? \"1\" : \"0\";\n        }\n      },\n      { threshold: 0.4 }\n    );\n    for (const el of els) {\n      el.style.transition = \"opacity 200ms ease-out\";\n      io.observe(el);\n    }\n    return () => io.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [reduced, labels.length]);\n\n  return (\n    <section\n      ref={sectionRef}\n      className={`relative h-[400vh] bg-background ${className}`}\n    >\n      <div className=\"sticky top-0 h-screen w-full overflow-hidden\">\n        <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 h-full w-full\" />\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0\"\n          style={{\n            background:\n              \"radial-gradient(circle at center, transparent 40%, var(--background) 96%)\",\n          }}\n        />\n        {!reduced &&\n          labels.map((label, i) => (\n            <div\n              key={`${label}-${i}`}\n              className=\"pointer-events-none absolute inset-0 flex items-center justify-center\"\n            >\n              <span\n                ref={(el) => {\n                  labelRefs.current[i] = el;\n                }}\n                className=\"font-mono text-sm tracking-widest text-foreground will-change-[transform,opacity,filter]\"\n                style={{ opacity: 0 }}\n              >\n                {label}\n              </span>\n            </div>\n          ))}\n      </div>\n      {reduced && (\n        <div className=\"absolute inset-0\">\n          {labels.map((label, i) => (\n            <span\n              key={`${label}-${i}`}\n              ref={(el) => {\n                labelRefs.current[i] = el;\n              }}\n              className=\"absolute left-1/2 -translate-x-1/2 -translate-y-1/2 font-mono text-sm tracking-widest text-foreground\"\n              style={{ top: `${((i + 0.5) * 100) / labels.length}%`, opacity: 0 }}\n            >\n              {label}\n            </span>\n          ))}\n        </div>\n      )}\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/scroll-particle-tunnel.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "scroll",
      "canvas",
      "particles",
      "3d",
      "parallax",
      "scrub",
      "typography",
      "monochrome"
    ],
    "instruction": "Build a scroll-scrubbed particle tunnel section: a 400vh wrapper with a position:sticky full-viewport Canvas 2D inside it, roughly 3000 points seeded in a cylinder shell (radius 120-900 world units, depth 0-4000) and projected manually with scale = 600/(z - camZ), culling anything behind the camera. Read scroll progress from the section's getBoundingClientRect in a passive scroll listener that writes a plain variable, map it to camZ = p*3600, and drive everything from a direct-DOM rAF loop with no React state on the hot path that sleeps once scroll, pointer parallax, and springs are all settled. Keep an EMA (alpha=0.12) of scroll velocity and stretch each point into a motion streak along its per-frame projected delta with length clamp(|v|*0.06, 0, 40)px, relaxed back to dots by a spring (k=60, zeta=0.8) when scrolling stops; add cursor parallax by lerping camera x/y at 0.08/frame toward pointer offset from viewport center times 0.04. Points are drawn from the live --foreground token (read via getComputedStyle at mount, re-derived through a MutationObserver on <html> class changes) with alpha rising 0.3 to 1.0 proportional to 1/(z - camZ), so near/far contrast stays correct in both light and dark themes instead of a fixed gray ramp tuned for one background. A theme-token radial-gradient vignette (transparent center to var(--background) at the edges) sits over the canvas to sell the tunnel depth on the static pre-scroll frame. Overlay four Geist Mono labels (text-sm tracking-widest) centered on screen at fixed world depths 700/1600/2500/3400 and write their opacity, blur, and scale directly to style each frame: fully focused within +-300 units of camZ, falling to opacity 0 and blur 6px over the next 900 units, scale running 0.92 to 1.08 through the pass so they fly by. Under prefers-reduced-motion draw one static starfield frame (also theme-aware and re-derived on theme change) and render the labels as a normal stacked list that fades in over 200ms via IntersectionObserver."
  },
  "type": "registry:ui"
}