{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-particles-webgl",
  "title": "Hero Particles WEBGL",
  "description": "Full-viewport hero with a cursor-reactive WebGL particle field and staggered text reveal.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "motion"
  ],
  "files": [
    {
      "path": "registry/core/hero-particles-webgl/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type RefObject } from \"react\";\nimport { Canvas, useFrame, useThree } from \"@react-three/fiber\";\nimport * as THREE from \"three\";\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\n\nconst COUNT = 6000;\nconst FIELD = { w: 22, h: 12 };\n// repulsion radius in screen pixels; converted to world units per-frame\n// since world-units-per-pixel changes with viewport size\nconst CURSOR_RADIUS_PX = 170;\n// peak outward displacement as a fraction of the radius\nconst PUSH = 0.62;\n// resting cursor position, far outside the field — falloff is 0 here, so\n// easing back toward it lets every dot relax to its home position\nconst REST = new THREE.Vector2(999, 999);\n// exponential ease factor for the cursor uniform: low enough that dots trail\n// the pointer and spring back rather than snapping\nconst EASE = 0.09;\n\n// All particle motion lives in the vertex shader — zero per-particle CPU work.\n// JS only eases the cursor uniform each frame so the repulsion trails smoothly,\n// and re-derives uColor from CSS tokens on theme change.\nconst vertexShader = /* glsl */ `\n  uniform float uTime;\n  uniform vec2 uCursor;\n  uniform float uRadius;\n  uniform float uPush;\n  attribute float aSeed;\n  void main() {\n    vec3 p = position;\n    // ambient drift — two desynced sines per dot\n    p.x += sin(uTime * 0.4 + aSeed) * 0.3;\n    p.y += cos(uTime * 0.32 + aSeed * 1.7) * 0.3;\n    // reversed-polarity magnet: displace outward along (dot - cursor),\n    // strongest at the pointer, decaying smoothly to zero at uRadius\n    vec2 d = p.xy - uCursor;\n    float dist = length(d);\n    float falloff = 1.0 - smoothstep(0.0, uRadius, dist);\n    // ease-out shaping so the shove is soft at the rim and firm at the core\n    falloff = falloff * falloff * (3.0 - 2.0 * falloff);\n    vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0);\n    p.xy += dir * falloff * uRadius * uPush;\n    vec4 mv = modelViewMatrix * vec4(p, 1.0);\n    gl_PointSize = 42.0 / -mv.z;\n    gl_Position = projectionMatrix * mv;\n  }\n`;\n\nconst fragmentShader = /* glsl */ `\n  uniform vec3 uColor;\n  void main() {\n    float d = length(gl_PointCoord - 0.5);\n    float alpha = smoothstep(0.5, 0.15, d) * 0.55;\n    if (alpha < 0.01) discard;\n    gl_FragColor = vec4(uColor, alpha);\n  }\n`;\n\n// #rrggbb / #rgb -> 0-1 float triple; falls back to muted gray on parse miss\nfunction parseColor(hex: string): [number, number, number] | null {\n  const s = hex.trim();\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\nfunction Particles({ still, visible }: { still: boolean; visible: RefObject<boolean> }) {\n  const material = useRef<THREE.ShaderMaterial>(null);\n  const pointerInside = useRef(false);\n  // scratch vector for the per-frame ease target — avoids an allocation on\n  // every frame the pointer is inside the canvas\n  const target = useRef(new THREE.Vector2());\n  const { viewport, pointer, size, gl } = useThree();\n\n  const { positions, seeds } = useMemo(() => {\n    const positions = new Float32Array(COUNT * 3);\n    const seeds = new Float32Array(COUNT);\n    for (let i = 0; i < COUNT; i++) {\n      positions[i * 3] = (Math.random() - 0.5) * FIELD.w;\n      positions[i * 3 + 1] = (Math.random() - 0.5) * FIELD.h;\n      positions[i * 3 + 2] = 0;\n      seeds[i] = Math.random() * Math.PI * 2;\n    }\n    return { positions, seeds };\n  }, []);\n\n  const uniforms = useMemo(\n    () => ({\n      uTime: { value: 0 },\n      uCursor: { value: REST.clone() },\n      uRadius: { value: 1 },\n      uPush: { value: 0 },\n      uColor: { value: new THREE.Vector3(0.561, 0.561, 0.561) },\n    }),\n    []\n  );\n\n  // track pointer enter/leave on the canvas element directly — pointer.x/y\n  // from useThree freezes at the last in-bounds sample on leave, so without\n  // this the cursor uniform would never ease back to rest\n  useEffect(() => {\n    const el = gl.domElement;\n    const enter = () => (pointerInside.current = true);\n    const leave = () => (pointerInside.current = false);\n    el.addEventListener(\"pointerenter\", enter);\n    el.addEventListener(\"pointerleave\", leave);\n    return () => {\n      el.removeEventListener(\"pointerenter\", enter);\n      el.removeEventListener(\"pointerleave\", leave);\n    };\n  }, [gl]);\n\n  // derive dot color from CSS tokens at mount, and again whenever the theme\n  // class flips — mirrors the pattern used by other canvas components in\n  // this registry (e.g. hero-long-exposure)\n  useEffect(() => {\n    const derive = () => {\n      const u = material.current?.uniforms;\n      if (!u) return;\n      const cs = getComputedStyle(document.documentElement);\n      const base = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? [0.561, 0.561, 0.561];\n      (u.uColor.value as THREE.Vector3).set(...base);\n    };\n    derive();\n    const mo = new MutationObserver(derive);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n    return () => mo.disconnect();\n  }, []);\n\n  useFrame(({ clock }) => {\n    if (!material.current || !visible.current) return; // paused while scrolled offscreen\n    const u = material.current.uniforms;\n    // radius is defined in screen pixels; convert using this frame's\n    // world-units-per-pixel so the void stays ~170px regardless of\n    // viewport size or DPR\n    u.uRadius.value = (CURSOR_RADIUS_PX * viewport.width) / size.width;\n    if (still) return; // reduced motion: freeze drift and repulsion\n    u.uTime.value = clock.elapsedTime;\n\n    const inside = pointerInside.current;\n    const t = target.current;\n    if (inside) t.set((pointer.x * viewport.width) / 2, (pointer.y * viewport.height) / 2);\n    else t.copy(REST);\n    // exponential ease toward target — dots swell outward as the pointer\n    // approaches and relax home behind it\n    const c = u.uCursor.value as THREE.Vector2;\n    c.x += (t.x - c.x) * EASE;\n    c.y += (t.y - c.y) * EASE;\n    // fade the push in on enter / out on leave so nothing pops\n    const p = u.uPush as { value: number };\n    p.value += ((inside ? PUSH : 0) - p.value) * EASE;\n  });\n\n  return (\n    <points>\n      <bufferGeometry>\n        <bufferAttribute attach=\"attributes-position\" args={[positions, 3]} />\n        <bufferAttribute attach=\"attributes-aSeed\" args={[seeds, 1]} />\n      </bufferGeometry>\n      <shaderMaterial\n        ref={material}\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n        uniforms={uniforms}\n        transparent\n        depthWrite={false}\n      />\n    </points>\n  );\n}\n\nfunction useWebGLSupport() {\n  const [supported, setSupported] = useState(true);\n  useEffect(() => {\n    const canvas = document.createElement(\"canvas\");\n    setSupported(!!(canvas.getContext(\"webgl2\") || canvas.getContext(\"webgl\")));\n  }, []);\n  return supported;\n}\n\nconst reveal: Variants = {\n  hidden: { opacity: 0, y: 24, filter: \"blur(8px)\" },\n  show: (i: number) => ({\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { delay: 0.15 + i * 0.12, type: \"spring\", stiffness: 90, damping: 16 },\n  }),\n};\n\nconst ctaClasses =\n  \"inline-flex items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\";\n\nconst revealProps = (reduced: boolean, i: number) =>\n  ({ variants: reveal, initial: reduced ? false : \"hidden\", animate: \"show\", custom: i }) as const;\n\nexport function ParticleHero({\n  eyebrow = \"ns-ui\",\n  headline = \"Interfaces with gravity.\",\n  subline = \"A registry of components built one at a time, each earning its place.\",\n  cta = \"Browse components\",\n  ctaHref,\n  onCtaClick,\n}: {\n  /** mono eyebrow label above the headline */\n  eyebrow?: string;\n  /** main hero heading */\n  headline?: string;\n  /** supporting copy under the headline */\n  subline?: string;\n  /** CTA button/link text */\n  cta?: string;\n  /** renders the CTA as an `<a href>` instead of a `<button>` */\n  ctaHref?: string;\n  /** click handler when `ctaHref` is omitted (renders a `<button>`) */\n  onCtaClick?: () => void;\n}) {\n  const webgl = useWebGLSupport();\n  const reduced = useReducedMotion() ?? false;\n  const sectionRef = useRef<HTMLElement>(null);\n  const visible = useRef(true);\n\n  // pause the rAF-driven drift/cursor work while the hero is scrolled offscreen\n  useEffect(() => {\n    const el = sectionRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([entry]) => {\n      visible.current = entry.isIntersecting;\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  return (\n    <section\n      ref={sectionRef}\n      className=\"relative flex min-h-screen items-center justify-center overflow-hidden bg-background\"\n    >\n      {webgl ? (\n        <div className=\"absolute inset-0\" aria-hidden>\n          <Canvas camera={{ position: [0, 0, 8], fov: 50 }} dpr={[1, 2]}>\n            <Particles still={reduced} visible={visible} />\n          </Canvas>\n        </div>\n      ) : (\n        // static fallback when WebGL is unavailable\n        <div\n          aria-hidden\n          className=\"absolute inset-0 [background-image:radial-gradient(circle,var(--border)_1px,transparent_1px)] [background-size:22px_22px]\"\n        />\n      )}\n      {/* vignette keeps edges quiet and text legible; pointer-events-none so\n          the cursor reaches the canvas underneath (it blocked all hover) */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_50%,transparent_30%,var(--background)_100%)]\"\n      />\n      <div className=\"relative z-10 mx-auto max-w-2xl px-6 text-center\">\n        <motion.p\n          className=\"font-mono text-xs uppercase tracking-[0.2em] text-ns-muted\"\n          {...revealProps(reduced, 0)}\n        >\n          {eyebrow}\n        </motion.p>\n        <motion.h1\n          className=\"ns-phw-h1 mt-4 text-5xl font-semibold tracking-tight sm:text-6xl\"\n          {...revealProps(reduced, 1)}\n        >\n          {headline}\n        </motion.h1>\n        <motion.p\n          className=\"mx-auto mt-5 max-w-lg text-base text-ns-muted\"\n          {...revealProps(reduced, 2)}\n        >\n          {subline}\n        </motion.p>\n        <motion.div\n          className=\"mt-8\"\n          {...revealProps(reduced, 3)}\n        >\n          {ctaHref ? (\n            <a href={ctaHref} className={ctaClasses}>\n              {cta}\n            </a>\n          ) : (\n            <button onClick={onCtaClick} className={ctaClasses}>\n              {cta}\n            </button>\n          )}\n        </motion.div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hero-particles-webgl.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "hero",
      "webgl",
      "particles",
      "text-reveal"
    ],
    "instruction": "A dark full-viewport hero section: a WebGL particle field of thousands of small gray dots that drift gently, and the cursor acts as a reversed-polarity magnet — dots within roughly 170px are displaced outward along the (dot - cursor) direction with a smooth falloff, opening an elastic void that trails the pointer and springs back to rest when it leaves. No glow, no halo, no linking lines; brightness and dot size never change. Behind it sits a staggered spring-physics text reveal (mono eyebrow, large tight headline, muted subline) and a single accent CTA, with a radial vignette keeping edges quiet. Strictly monochrome dots from the muted token, no accent. Static dot-grid fallback without WebGL, reduced-motion respected (drift and repulsion frozen)."
  },
  "type": "registry:ui"
}