{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "light-rays",
  "type": "registry:ui",
  "title": "Light Rays",
  "description": "Soft volumetric god rays fanning from a light source, swaying slowly with drifting dust motes.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/light-rays.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type LightRaysSource = \"top\" | \"top-left\" | \"top-right\";\n\nexport interface LightRaysProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of beams per layer (three layers are stacked). */\n  rayCount?: number;\n  /** Angular width of the beam fan, in degrees. */\n  spread?: number;\n  /** Overall strength of the beams, 0–1. */\n  intensity?: number;\n  /** Sway and dust drift speed multiplier. 0 holds the rays still. */\n  speed?: number;\n  /** Light colour (any CSS colour). */\n  color?: string;\n  /** Where the light comes from. Overridden by `sourceX` / `sourceY`. */\n  source?: LightRaysSource;\n  /** Custom source position in % of the width (may be outside 0–100). */\n  sourceX?: number;\n  /** Custom source position in % of the height (may be outside 0–100). */\n  sourceY?: number;\n  /** Floating dust motes inside the beams. */\n  dust?: boolean;\n  /** Let a mouse pointer gently shift the light source. */\n  pointer?: boolean;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\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\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- Helpers --------------------------------- */\n\nconst SOURCES: Record<LightRaysSource, [number, number]> = {\n  top: [50, -6],\n  \"top-left\": [4, -8],\n  \"top-right\": [96, -8],\n};\n\n/** Deterministic PRNG so server and client render identical gradients. */\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/** Conic direction (clockwise from up) from the source toward the bottom centre, in degrees. */\nfunction aim(sx: number, sy: number, width: number, height: number) {\n  const dx = (0.5 - sx / 100) * width;\n  const dy = (1 - sy / 100) * height;\n  return r2((Math.atan2(dx, -dy) * 180) / Math.PI);\n}\n\n/** One layer of soft beams: `count` wedges with random width, offset and strength inside the fan. */\nfunction beamGradient(count: number, spread: number, seed: number, at: string) {\n  const rand = mulberry32(seed);\n  const slot = spread / count;\n  const stops: string[] = [\"transparent 0deg\"];\n  for (let i = 0; i < count; i++) {\n    const width = slot * (0.3 + rand() * 0.6);\n    const start = i * slot + rand() * (slot - width);\n    const strength = Math.round(28 + rand() * 72);\n    stops.push(\n      `transparent ${r2(start)}deg`,\n      `color-mix(in srgb, var(--lr-color) ${strength}%, transparent) ${r2(start + width / 2)}deg`,\n      `transparent ${r2(start + width)}deg`,\n    );\n  }\n  stops.push(`transparent ${r2(spread)}deg`);\n  return `conic-gradient(from calc(var(--lr-dir) - ${r2(spread / 2)}deg) at ${at}, ${stops.join(\", \")})`;\n}\n\nconst LAYERS = [\n  { seed: 11, opacity: 0.6, sway: 2.6, duration: 15000 },\n  { seed: 29, opacity: 0.42, sway: -3.4, duration: 19000 },\n  { seed: 47, opacity: 0.3, sway: 4.2, duration: 23000 },\n];\nconst MOTE_COUNT = 28;\nconst BOX_PAD = 10; // the drawing box extends 10% past each edge, so pointer shifts never reveal an edge\nconst LAYER_PAD = 15; // beam layers extend a further 15%, so swaying never reveals an edge\nconst MAX_SHIFT = 28; // px\n\n/** Dust motes: fixed, seeded positions (identical on server and client). */\nconst MOTES = Array.from({ length: MOTE_COUNT }, (_, i) => {\n  const rand = mulberry32(900 + i * 7);\n  return {\n    left: r2(BOX_PAD + 8 + rand() * 84),\n    top: r2(BOX_PAD + 4 + rand() * 80),\n    size: r2(1.2 + rand() * 2.4),\n    dx: r2((rand() - 0.5) * 60),\n    dy: r2(-20 - rand() * 50),\n    peak: r2(0.35 + rand() * 0.55),\n    duration: 7000 + rand() * 9000,\n    phase: rand(),\n  };\n});\n\n/**\n * Soft volumetric light rays built from three stacked conic gradients, each swaying slowly\n * around the light source with the Web Animations API — the motion is composited, so the main\n * thread does no per-frame work. Dust motes drift inside the beams, and a mouse pointer nudges\n * the whole source a few pixels.\n */\nexport function LightRays({\n  rayCount = 12,\n  spread = 70,\n  intensity = 0.6,\n  speed = 1,\n  color = \"#FF7448\",\n  source = \"top\",\n  sourceX,\n  sourceY,\n  dust = true,\n  pointer = true,\n  className = \"\",\n  style,\n  children,\n  ...props\n}: LightRaysProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const shiftRef = React.useRef<HTMLDivElement>(null);\n  const layerRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const moteRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n\n  const [sx, sy] = [sourceX ?? SOURCES[source][0], sourceY ?? SOURCES[source][1]];\n  const count = Math.max(1, Math.round(rayCount));\n  const fan = Math.min(180, Math.max(5, spread));\n  const strength = Math.min(1, Math.max(0, intensity));\n\n  // Source position inside the padded box, and inside each (further padded) beam layer.\n  const bx = r2((sx + BOX_PAD) / (1 + BOX_PAD / 50));\n  const by = r2((sy + BOX_PAD) / (1 + BOX_PAD / 50));\n  const lx = r2((bx + LAYER_PAD) / (1 + LAYER_PAD / 50));\n  const ly = r2((by + LAYER_PAD) / (1 + LAYER_PAD / 50));\n\n  // Aim the fan at the bottom centre for the container's real aspect ratio.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver(() => {\n      const { width, height } = root.getBoundingClientRect();\n      if (width && height) root.style.setProperty(\"--lr-dir\", `${aim(sx, sy, width * 1.2, height * 1.2)}deg`);\n    });\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, [sx, sy]);\n\n  // Sway, dust drift and the pointer shift. Everything pauses offscreen and in hidden tabs.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const shift = shiftRef.current;\n    if (!root || !shift || reduceMotion) return;\n\n    const animations: Animation[] = [];\n    if (speed > 0) {\n      LAYERS.forEach((layer, i) => {\n        const el = layerRefs.current[i];\n        if (!el) return;\n        const o = layer.opacity * strength;\n        const a = el.animate(\n          [\n            { transform: `rotate(${-layer.sway}deg)`, opacity: o * 0.8 },\n            { transform: `rotate(${layer.sway}deg)`, opacity: o },\n          ],\n          { duration: layer.duration / speed, direction: \"alternate\", iterations: Infinity, easing: \"ease-in-out\", delay: -layer.duration * 0.37 * i },\n        );\n        animations.push(a);\n      });\n      MOTES.forEach((m, i) => {\n        const el = moteRefs.current[i];\n        if (!el) return;\n        const duration = m.duration / speed;\n        const a = el.animate(\n          [\n            { transform: \"translate(0px, 0px)\", opacity: 0 },\n            { opacity: m.peak, offset: 0.5 },\n            { transform: `translate(${m.dx}px, ${m.dy}px)`, opacity: 0 },\n          ],\n          { duration, iterations: Infinity, easing: \"ease-in-out\", delay: -duration * m.phase },\n        );\n        animations.push(a);\n      });\n    }\n\n    // Pointer: ease the whole light box toward a small offset that follows the mouse.\n    const target = { x: 0, y: 0 };\n    const current = { x: 0, y: 0 };\n    let raf = 0;\n    let inView = false;\n    const tick = () => {\n      raf = 0;\n      current.x += (target.x - current.x) * 0.06;\n      current.y += (target.y - current.y) * 0.06;\n      shift.style.transform = `translate3d(${current.x.toFixed(2)}px, ${current.y.toFixed(2)}px, 0)`;\n      if (Math.abs(target.x - current.x) > 0.05 || Math.abs(target.y - current.y) > 0.05) wake();\n    };\n    const wake = () => {\n      if (!raf && inView && !document.hidden) raf = requestAnimationFrame(tick);\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return;\n      const rect = root.getBoundingClientRect();\n      const nx = (e.clientX - rect.left) / rect.width - 0.5;\n      const ny = (e.clientY - rect.top) / rect.height - 0.5;\n      const inside = Math.abs(nx) <= 0.5 && Math.abs(ny) <= 0.5;\n      target.x = inside ? nx * 2 * MAX_SHIFT : 0;\n      target.y = inside ? ny * MAX_SHIFT * 0.6 : 0;\n      wake();\n    };\n    const onPointerLeave = () => {\n      target.x = 0;\n      target.y = 0;\n      wake();\n    };\n\n    const sync = () => {\n      const run = inView && !document.hidden;\n      for (const a of animations) {\n        if (run) a.play();\n        else a.pause();\n      }\n      if (run) wake();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (pointer) {\n        if (inView) {\n          window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        } else {\n          window.removeEventListener(\"pointermove\", onPointerMove);\n          document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        }\n      }\n      sync();\n    });\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n      cancelAnimationFrame(raf);\n      for (const a of animations) a.cancel();\n      shift.style.transform = \"\";\n    };\n  }, [reduceMotion, speed, strength, pointer, dust]);\n\n  const boxInset = `-${BOX_PAD}%`;\n  const layerInset = `-${LAYER_PAD}%`;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`absolute inset-0 overflow-hidden ${className}`}\n      style={{\n        [\"--lr-color\" as string]: color,\n        [\"--lr-dir\" as string]: `${aim(sx, sy, 16, 9)}deg`,\n        ...style,\n      }}\n      {...props}\n    >\n      <div ref={shiftRef} aria-hidden=\"true\" className=\"pointer-events-none absolute will-change-transform\" style={{ inset: boxInset }}>\n        {/* Light themes get a slightly softer mix so the beams read as warmth, not paint. */}\n        <div\n          className=\"absolute inset-0 opacity-80 dark:opacity-100\"\n          style={{\n            maskImage: `radial-gradient(circle farthest-corner at ${bx}% ${by}%, #000 0%, rgba(0,0,0,0.55) 38%, transparent 82%)`,\n            WebkitMaskImage: `radial-gradient(circle farthest-corner at ${bx}% ${by}%, #000 0%, rgba(0,0,0,0.55) 38%, transparent 82%)`,\n          }}\n        >\n          {LAYERS.map((layer, i) => (\n            <div\n              key={layer.seed}\n              ref={(el) => {\n                layerRefs.current[i] = el;\n              }}\n              className=\"absolute will-change-transform\"\n              style={{\n                inset: layerInset,\n                backgroundImage: beamGradient(count, fan, layer.seed + count * 3, `${lx}% ${ly}%`),\n                transformOrigin: `${lx}% ${ly}%`,\n                opacity: r2(layer.opacity * strength),\n              }}\n            />\n          ))}\n          {/* Broad haze under the beams, so the fan reads as volume rather than stripes. */}\n          <div\n            className=\"absolute inset-0\"\n            style={{\n              backgroundImage: `conic-gradient(from calc(var(--lr-dir) - ${r2(fan * 0.6)}deg) at ${bx}% ${by}%, transparent 0deg, color-mix(in srgb, var(--lr-color) ${Math.round(26 * strength)}%, transparent) ${r2(fan * 0.6)}deg, transparent ${r2(fan * 1.2)}deg)`,\n            }}\n          />\n          {/* Hot spot at the source. */}\n          <div\n            className=\"absolute inset-0\"\n            style={{\n              backgroundImage: `radial-gradient(circle at ${bx}% ${by}%, color-mix(in srgb, var(--lr-color) ${Math.round(70 * strength)}%, transparent) 0%, transparent 28%)`,\n            }}\n          />\n          {dust &&\n            MOTES.map((m, i) => (\n              <span\n                key={i}\n                ref={(el) => {\n                  moteRefs.current[i] = el;\n                }}\n                className=\"absolute rounded-full bg-[color-mix(in_srgb,var(--lr-color)_70%,#000)] dark:bg-[color-mix(in_srgb,var(--lr-color)_45%,#fff)]\"\n                style={{\n                  left: `${m.left}%`,\n                  top: `${m.top}%`,\n                  width: m.size,\n                  height: m.size,\n                  opacity: reduceMotion || speed === 0 ? r2(m.peak * 0.6) : 0,\n                }}\n              />\n            ))}\n        </div>\n      </div>\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/light-rays"
}
