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