{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle-sphere",
  "type": "registry:ui",
  "title": "Particle Sphere",
  "description": "A rotating Fibonacci-lattice particle sphere on canvas that tilts toward and parts around the pointer.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/particle-sphere.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface ParticleSphereProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Colour of particles facing the viewer. */\n  color?: string;\n  /** Colour particles fade toward on the far side. */\n  secondaryColor?: string;\n  /** Number of particles before adaptive reduction. */\n  density?: number;\n  /** Particle radius in CSS px (front-most particles). */\n  size?: number;\n  /** Rotation speed multiplier (0 stops the spin). */\n  speed?: number;\n  /** Sphere radius as a fraction of the smaller container side. */\n  radius?: number;\n  /** How particles react to the pointer. */\n  interaction?: \"repel\" | \"attract\" | \"none\";\n  /** Tilt the sphere toward the pointer. */\n  tilt?: boolean;\n  /** Reduce particles on small screens and when the frame rate drops. */\n  adaptive?: boolean;\n  /** Soft halo behind the sphere. */\n  glow?: boolean;\n  /** Accessible label. Without one the canvas is treated as decorative. */\n  label?: string;\n}\n\ntype RGB = [number, number, number];\n\nfunction parseHex(hex: string, fallback: RGB): RGB {\n  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());\n  if (!m) return fallback;\n  const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];\n  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n}\n\n/** Evenly distributed unit-sphere points (Fibonacci lattice), packed as [x, y, z, …]. */\nfunction fibonacciSphere(count: number) {\n  const points = new Float32Array(count * 3);\n  const golden = Math.PI * (3 - Math.sqrt(5));\n  for (let i = 0; i < count; i++) {\n    const y = 1 - ((i + 0.5) * 2) / count;\n    const r = Math.sqrt(1 - y * y);\n    const theta = i * golden;\n    points[i * 3] = Math.cos(theta) * r;\n    points[i * 3 + 1] = y;\n    points[i * 3 + 2] = Math.sin(theta) * r;\n  }\n  return points;\n}\n\nconst BUCKETS = 7;\nconst FOV = 3.2; // camera distance in sphere radii\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * A rotating sphere of particles drawn on a 2D canvas. Points are projected with perspective,\n * depth-shaded in batches, and displaced around the pointer. The loop pauses offscreen and in\n * background tabs, and a single static frame is drawn for reduced motion.\n */\nexport function ParticleSphere({\n  color = \"#FF7448\",\n  secondaryColor = \"#B6F3D1\",\n  density = 900,\n  size = 1.6,\n  speed = 1,\n  radius = 0.36,\n  interaction = \"repel\",\n  tilt = true,\n  adaptive = true,\n  glow = true,\n  label,\n  className = \"\",\n  ...props\n}: ParticleSphereProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!root || !canvas || !ctx) return;\n\n    const front = parseHex(color, [255, 116, 72]);\n    const back = parseHex(secondaryColor, [182, 243, 209]);\n    // Pre-computed fill styles per depth bucket (far → near).\n    const fills = Array.from({ length: BUCKETS }, (_, b) => {\n      const t = b / (BUCKETS - 1);\n      const mix = (i: number) => Math.round(back[i] + (front[i] - back[i]) * t);\n      const alpha = 0.14 + 0.86 * Math.pow(t, 1.4);\n      return `rgba(${mix(0)}, ${mix(1)}, ${mix(2)}, ${alpha.toFixed(3)})`;\n    });\n\n    const target = Math.max(20, Math.round(density));\n    let count = target;\n    let points = fibonacciSphere(count);\n    let sx = new Float32Array(count);\n    let sy = new Float32Array(count);\n    let sr = new Float32Array(count);\n    let bucket = new Uint8Array(count);\n    const setCount = (next: number) => {\n      count = next;\n      points = fibonacciSphere(count);\n      sx = new Float32Array(count);\n      sy = new Float32Array(count);\n      sr = new Float32Array(count);\n      bucket = new Uint8Array(count);\n    };\n\n    let width = 0;\n    let height = 0;\n    let angle = 0.6;\n    let tiltX = -0.25;\n    let tiltY = 0;\n    const pointer = { x: 0, y: 0, inside: false, strength: 0, tx: 0, ty: 0 };\n    let frame = 0;\n    let running = false;\n    let visible = true;\n    let last = 0;\n    let sampleTime = 0;\n    let sampleFrames = 0;\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height);\n      const cx = width / 2;\n      const cy = height / 2;\n      const R = Math.min(width, height) * radius;\n      if (R <= 0) return;\n\n      if (glow) {\n        const g = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.5);\n        g.addColorStop(0, `rgba(${front[0]}, ${front[1]}, ${front[2]}, 0.16)`);\n        g.addColorStop(1, `rgba(${front[0]}, ${front[1]}, ${front[2]}, 0)`);\n        ctx.fillStyle = g;\n        ctx.fillRect(0, 0, width, height);\n      }\n\n      const cosY = Math.cos(angle + tiltY);\n      const sinY = Math.sin(angle + tiltY);\n      const cosX = Math.cos(tiltX);\n      const sinX = Math.sin(tiltX);\n      const influence = R * 0.55;\n      const push = R * 0.22 * pointer.strength;\n      const interact = interaction !== \"none\" && pointer.strength > 0.01;\n\n      for (let i = 0; i < count; i++) {\n        const px = points[i * 3];\n        const py = points[i * 3 + 1];\n        const pz = points[i * 3 + 2];\n        // Rotate around Y, then X.\n        const x1 = cosY * px + sinY * pz;\n        const z1 = -sinY * px + cosY * pz;\n        const y2 = cosX * py - sinX * z1;\n        const z2 = sinX * py + cosX * z1;\n        const persp = FOV / (FOV - z2);\n        let x = cx + x1 * R * persp;\n        let y = cy + y2 * R * persp;\n        const depth = (z2 + 1) / 2;\n\n        if (interact) {\n          const dx = x - pointer.x;\n          const dy = y - pointer.y;\n          const d = Math.sqrt(dx * dx + dy * dy) || 1;\n          if (d < influence) {\n            const falloff = 1 - d / influence;\n            // Near particles respond more than those on the far side.\n            const f = falloff * falloff * push * (0.35 + 0.65 * depth);\n            const dir = interaction === \"repel\" ? 1 : -Math.min(1, d / (f + 0.0001));\n            x += (dx / d) * f * dir;\n            y += (dy / d) * f * dir;\n          }\n        }\n\n        sx[i] = x;\n        sy[i] = y;\n        sr[i] = size * (0.45 + 0.75 * depth) * persp;\n        bucket[i] = Math.min(BUCKETS - 1, Math.floor(depth * BUCKETS));\n      }\n\n      // One path per depth bucket keeps fill-style changes (and draw calls) to a handful.\n      for (let b = 0; b < BUCKETS; b++) {\n        ctx.beginPath();\n        for (let i = 0; i < count; i++) {\n          if (bucket[i] !== b) continue;\n          ctx.moveTo(sx[i] + sr[i], sy[i]);\n          ctx.arc(sx[i], sy[i], sr[i], 0, Math.PI * 2);\n        }\n        ctx.fillStyle = fills[b];\n        ctx.fill();\n      }\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      width = rect.width;\n      height = rect.height;\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      if (adaptive && width < 520 && count === target) setCount(Math.max(120, Math.round(target * 0.6)));\n      if (reduceMotion || !running) draw();\n    };\n\n    // Animation loop with offscreen / hidden-tab pausing and adaptive quality.\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      angle += dt * 0.32 * speed;\n\n      const ease = 1 - Math.pow(0.002, dt); // frame-rate independent smoothing\n      pointer.strength += ((pointer.inside ? 1 : 0) - pointer.strength) * ease;\n      const tx = tilt && pointer.inside ? pointer.ty * 0.5 - 0.25 : -0.25;\n      const ty = tilt && pointer.inside ? pointer.tx * 0.6 : 0;\n      tiltX += (tx - tiltX) * ease;\n      tiltY += (ty - tiltY) * ease;\n\n      draw();\n\n      if (adaptive) {\n        sampleTime += dt;\n        sampleFrames++;\n        if (sampleFrames >= 90) {\n          const avg = sampleTime / sampleFrames;\n          // Below ~40fps: shed a quarter of the particles (never below 30% of the target).\n          if (avg > 1 / 40 && count > target * 0.3) setCount(Math.round(count * 0.75));\n          sampleTime = 0;\n          sampleFrames = 0;\n        }\n      }\n      frame = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (running || reduceMotion || !visible || document.hidden) return;\n      running = true;\n      last = 0;\n      frame = requestAnimationFrame(loop);\n    };\n    const stop = () => {\n      running = false;\n      cancelAnimationFrame(frame);\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) start();\n      else stop();\n    });\n    io.observe(root);\n    const onVisibility = () => (document.hidden ? stop() : start());\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const onMove = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      pointer.x = event.clientX - rect.left;\n      pointer.y = event.clientY - rect.top;\n      pointer.tx = (pointer.x / rect.width) * 2 - 1;\n      pointer.ty = (pointer.y / rect.height) * 2 - 1;\n      pointer.inside = true;\n    };\n    const onLeave = () => {\n      pointer.inside = false;\n    };\n    if (!reduceMotion) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerdown\", onMove);\n      root.addEventListener(\"pointerleave\", onLeave);\n      root.addEventListener(\"pointercancel\", onLeave);\n    }\n\n    resize();\n    start();\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointercancel\", onLeave);\n    };\n  }, [color, secondaryColor, density, size, speed, radius, interaction, tilt, adaptive, glow, reduceMotion]);\n\n  return (\n    <div\n      ref={rootRef}\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      className={`relative h-full w-full touch-pan-y ${className}`}\n      {...props}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"absolute inset-0 block h-full w-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/particle-sphere"
}
