"use client";
import { useRef, useEffect, useState } from 'react'
import { Renderer, Program, Triangle, Mesh } from 'ogl'
import { useAnimationLoop, type Metrics } from '@/hooks/use-animation-loop'
export type RaysOrigin =
| 'top-center'
| 'top-left'
| 'top-right'
| 'right'
| 'left'
| 'bottom-center'
| 'bottom-right'
| 'bottom-left'
interface LightRaysProps {
raysOrigin?: RaysOrigin
raysColor?: string
raysSpeed?: number
lightSpread?: number
rayLength?: number
pulsating?: boolean
fadeDistance?: number
saturation?: number
followMouse?: boolean
mouseInfluence?: number
noiseAmount?: number
distortion?: number
maxDpr?: number
className?: string
}
const DEFAULT_COLOR = '#a855f7'
const FADE_MS = 700
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return m
? [
parseInt(m[1], 16) / 255,
parseInt(m[2], 16) / 255,
parseInt(m[3], 16) / 255,
]
: [1, 1, 1]
}
const getAnchorAndDir = (
origin: RaysOrigin,
w: number,
h: number
): { anchor: [number, number]; dir: [number, number] } => {
const outside = 0.2
switch (origin) {
case 'top-left':
return { anchor: [0, -outside * h], dir: [0, 1] }
case 'top-right':
return { anchor: [w, -outside * h], dir: [0, 1] }
case 'left':
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }
case 'right':
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }
case 'bottom-left':
return { anchor: [0, (1 + outside) * h], dir: [0, -1] }
case 'bottom-center':
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }
case 'bottom-right':
return { anchor: [w, (1 + outside) * h], dir: [0, -1] }
default:
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }
}
}
type Vec2 = [number, number]
type Vec3 = [number, number, number]
interface Uniforms {
iTime: { value: number }
iResolution: { value: Vec2 }
rayPos: { value: Vec2 }
rayDir: { value: Vec2 }
raysColor: { value: Vec3 }
raysSpeed: { value: number }
lightSpread: { value: number }
rayLength: { value: number }
pulsating: { value: number }
fadeDistance: { value: number }
saturation: { value: number }
mousePos: { value: Vec2 }
mouseInfluence: { value: number }
noiseAmount: { value: number }
distortion: { value: number }
}
const LightRays: React.FC<LightRaysProps> = ({
raysOrigin = 'top-center',
raysColor = DEFAULT_COLOR,
raysSpeed = 1,
lightSpread = 1,
rayLength = 2,
pulsating = false,
fadeDistance = 1.0,
saturation = 1.0,
followMouse = true,
mouseInfluence = 0.1,
noiseAmount = 0.1,
distortion = 0.2,
maxDpr = 2,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const uniformsRef = useRef<Uniforms | null>(null)
const rendererRef = useRef<Renderer | null>(null)
const mouseRef = useRef({ x: 0.5, y: 0.5 })
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 })
const meshRef = useRef<Mesh | null>(null)
const cleanupFunctionRef = useRef<(() => void) | null>(null)
const [isVisible, setIsVisible] = useState(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const speedRef = useRef(raysSpeed)
const followMouseRef = useRef(followMouse)
const mouseInfluenceRef = useRef(mouseInfluence)
const drawRef = useRef<((t: number) => void | false) | null>(null)
const measureRef = useRef<((m: Metrics) => void) | null>(null)
const loop = useAnimationLoop({
target: containerRef,
halted: raysSpeed <= 0,
dpr: maxDpr,
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: ({ now }) => (drawRef.current ? drawRef.current(now) : false),
})
useEffect(() => {
if (!containerRef.current) return
observerRef.current = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 }
)
observerRef.current.observe(containerRef.current)
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}
}
}, [])
useEffect(() => {
if (!isVisible || !containerRef.current) return
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current()
cleanupFunctionRef.current = null
}
const initializeWebGL = async () => {
if (!containerRef.current) return
await new Promise((resolve) => setTimeout(resolve, 10))
if (!containerRef.current) return
const renderer = new Renderer({
dpr: Math.min(window.devicePixelRatio, maxDpr),
alpha: true,
premultipliedAlpha: true,
})
rendererRef.current = renderer
const gl = renderer.gl
gl.canvas.style.width = '100%'
gl.canvas.style.height = '100%'
gl.canvas.style.opacity = '0'
gl.canvas.style.transition = `opacity ${FADE_MS}ms ease`
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild)
}
containerRef.current.appendChild(gl.canvas)
const vert = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`
const frag = `precision highp float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
float baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
vec4 rays1 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * raysSpeed);
vec4 rays2 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * raysSpeed);
fragColor = rays1 * 0.5 + rays2 * 0.4;
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
float brightness = 1.0 - (coord.y / iResolution.y);
fragColor.x *= 0.1 + brightness * 0.8;
fragColor.y *= 0.3 + brightness * 0.6;
fragColor.z *= 0.5 + brightness * 0.5;
if (saturation != 1.0) {
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
}
fragColor.rgb *= raysColor;
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
// Premultiplied output — pairs with premultipliedAlpha:true so Safari
// composites the alpha-gated glow instead of painting the raw HDR RGB.
gl_FragColor = vec4(color.rgb * color.a, color.a);
}`
const uniforms: Uniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] },
rayPos: { value: [0, 0] },
rayDir: { value: [0, 1] },
raysColor: { value: hexToRgb(raysColor) },
raysSpeed: { value: raysSpeed },
lightSpread: { value: lightSpread },
rayLength: { value: rayLength },
pulsating: { value: pulsating ? 1.0 : 0.0 },
fadeDistance: { value: fadeDistance },
saturation: { value: saturation },
mousePos: { value: [0.5, 0.5] },
mouseInfluence: { value: mouseInfluence },
noiseAmount: { value: noiseAmount },
distortion: { value: distortion },
}
uniformsRef.current = uniforms
const geometry = new Triangle(gl)
const program = new Program(gl, {
vertex: vert,
fragment: frag,
uniforms,
})
const mesh = new Mesh(gl, { geometry, program })
meshRef.current = mesh
measureRef.current = ({ width, height, dpr, bufferWidth, bufferHeight }) => {
renderer.dpr = dpr
renderer.setSize(width, height)
uniforms.iResolution.value = [bufferWidth, bufferHeight]
const { anchor, dir } = getAnchorAndDir(
raysOrigin,
bufferWidth,
bufferHeight,
)
uniforms.rayPos.value = anchor
uniforms.rayDir.value = dir
}
let faded = false
const frame = (t: number) => {
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
return false
}
uniforms.iTime.value = t * 0.001
if (followMouseRef.current && mouseInfluenceRef.current > 0.0) {
const smoothing = 0.92
smoothMouseRef.current.x =
smoothMouseRef.current.x * smoothing +
mouseRef.current.x * (1 - smoothing)
smoothMouseRef.current.y =
smoothMouseRef.current.y * smoothing +
mouseRef.current.y * (1 - smoothing)
uniforms.mousePos.value = [
smoothMouseRef.current.x,
smoothMouseRef.current.y,
]
}
try {
renderer.render({ scene: mesh })
} catch (error) {
console.warn('WebGL rendering error:', error)
return false
}
if (!faded) {
faded = true
gl.canvas.style.opacity = '1'
}
if (speedRef.current <= 0) return false
}
drawRef.current = frame
loop.resize()
loop.start()
cleanupFunctionRef.current = () => {
drawRef.current = null
measureRef.current = null
const canvas = renderer.gl.canvas
const dispose = () => {
try {
renderer.gl.getExtension('WEBGL_lose_context')?.loseContext()
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas)
}
} catch (error) {
console.warn('Error during WebGL cleanup:', error)
}
}
if (canvas) {
canvas.style.opacity = '0'
window.setTimeout(dispose, FADE_MS + 40)
} else {
dispose()
}
if (rendererRef.current === renderer) rendererRef.current = null
uniformsRef.current = null
meshRef.current = null
}
}
initializeWebGL()
return () => {
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current()
cleanupFunctionRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible])
useEffect(() => {
speedRef.current = raysSpeed
followMouseRef.current = followMouse
mouseInfluenceRef.current = mouseInfluence
if (!uniformsRef.current || !containerRef.current || !rendererRef.current)
return
const u = uniformsRef.current
const renderer = rendererRef.current
u.raysColor.value = hexToRgb(raysColor)
u.raysSpeed.value = raysSpeed
u.lightSpread.value = lightSpread
u.rayLength.value = rayLength
u.pulsating.value = pulsating ? 1.0 : 0.0
u.fadeDistance.value = fadeDistance
u.saturation.value = saturation
u.mouseInfluence.value = mouseInfluence
u.noiseAmount.value = noiseAmount
u.distortion.value = distortion
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current
const dpr = renderer.dpr
const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr)
u.rayPos.value = anchor
u.rayDir.value = dir
if (raysSpeed > 0) loop.start()
}, [
raysColor,
raysSpeed,
lightSpread,
raysOrigin,
rayLength,
pulsating,
fadeDistance,
saturation,
followMouse,
mouseInfluence,
noiseAmount,
distortion,
])
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!containerRef.current || !rendererRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
const y = (e.clientY - rect.top) / rect.height
mouseRef.current = { x, y }
}
if (followMouse) {
window.addEventListener('pointermove', handlePointerMove)
return () => window.removeEventListener('pointermove', handlePointerMove)
}
}, [followMouse])
return (
<div
ref={containerRef}
className={`${followMouse ? 'touch-none' : 'pointer-events-none'} relative z-[3] h-full w-full overflow-hidden ${className}`.trim()}
/>
)
}
export default LightRays