UNPKG

@stianlarsen/react-light-beam

Version:

A customizable React component that creates a light beam effect using conic gradients. Powered by GSAP for maximum performance. Supports dark mode and various customization options.

450 lines (448 loc) 14.8 kB
"use client"; import gsap3 from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { useGSAP } from '@gsap/react'; import { useRef, useEffect, useState, useMemo } from 'react'; import { jsxs, jsx, Fragment } from 'react/jsx-runtime'; var useIsDarkmode = () => { const [isDarkmode, setIsDarkmodeActive] = useState(false); useEffect(() => { const matchMedia = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { console.log("Darkmode match?", matchMedia.matches); setIsDarkmodeActive(matchMedia.matches); }; setIsDarkmodeActive(matchMedia.matches); matchMedia.addEventListener("change", handleChange); handleChange(); return () => { matchMedia.removeEventListener("change", handleChange); }; }, []); return { isDarkmode }; }; var DustParticles = ({ config, beamColor }) => { const { enabled = false, count = 30, speed = 1, sizeRange = [1, 3], opacityRange = [0.2, 0.6], color } = config; const particles = useMemo(() => { if (!enabled) return []; return Array.from({ length: count }, (_, i) => { const x = Math.random() * 100; const y = Math.random() * 100; const size = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]); const opacity = opacityRange[0] + Math.random() * (opacityRange[1] - opacityRange[0]); const duration = (3 + Math.random() * 4) / speed; const delay = Math.random() * duration; return { id: `dust-${i}`, x, y, size, opacity, duration, delay }; }); }, [enabled, count, sizeRange, opacityRange, speed]); useGSAP( () => { if (!enabled || particles.length === 0) return; const timelines = []; particles.forEach((particle) => { const element = document.getElementById(particle.id); if (!element) return; const tl = gsap3.timeline({ repeat: -1, yoyo: true, delay: particle.delay }); tl.to(element, { y: `-=${20 + Math.random() * 30}`, // Float upward 20-50px x: `+=${Math.random() * 20 - 10}`, // Slight horizontal drift ±10px opacity: particle.opacity * 0.5, // Fade slightly duration: particle.duration, ease: "sine.inOut" }); timelines.push(tl); }); return () => { timelines.forEach((tl) => tl.kill()); }; }, { dependencies: [particles, enabled] } ); if (!enabled) return null; const particleColor = color || beamColor; return /* @__PURE__ */ jsx(Fragment, { children: particles.map((particle) => /* @__PURE__ */ jsx( "div", { id: particle.id, style: { position: "absolute", left: `${particle.x}%`, top: `${particle.y}%`, width: `${particle.size}px`, height: `${particle.size}px`, borderRadius: "50%", backgroundColor: particleColor, opacity: particle.opacity, pointerEvents: "none", willChange: "transform, opacity" } }, particle.id )) }); }; var MistEffect = ({ config, beamColor }) => { const { enabled = false, intensity = 0.3, speed = 1, layers = 2 } = config; const mistLayers = useMemo(() => { if (!enabled) return []; return Array.from({ length: layers }, (_, i) => { const layerOpacity = intensity * 0.6 / (i + 1); const duration = (8 + i * 3) / speed; const delay = i * 1.5 / speed; const scale = 1 + i * 0.2; return { id: `mist-layer-${i}`, opacity: layerOpacity, duration, delay, scale }; }); }, [enabled, intensity, speed, layers]); useGSAP( () => { if (!enabled || mistLayers.length === 0) return; const timelines = []; mistLayers.forEach((layer) => { const element = document.getElementById(layer.id); if (!element) return; const tl = gsap3.timeline({ repeat: -1, yoyo: false }); tl.fromTo( element, { x: "-100%", opacity: 0 }, { x: "100%", opacity: layer.opacity, duration: layer.duration, ease: "none", delay: layer.delay } ).to(element, { opacity: 0, duration: layer.duration * 0.2, ease: "power1.in" }); timelines.push(tl); }); return () => { timelines.forEach((tl) => tl.kill()); }; }, { dependencies: [mistLayers, enabled] } ); if (!enabled) return null; const mistColor = beamColor.replace(/[\d.]+\)$/g, `${intensity})`); return /* @__PURE__ */ jsx(Fragment, { children: mistLayers.map((layer) => /* @__PURE__ */ jsx( "div", { id: layer.id, style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", background: `radial-gradient(ellipse 120% 80% at 50% 20%, ${mistColor}, transparent 70%)`, opacity: 0, pointerEvents: "none", willChange: "transform, opacity", transform: `scale(${layer.scale})`, filter: "blur(40px)" } }, layer.id )) }); }; var PulseEffect = ({ config, containerRef }) => { const { enabled = false, duration = 2, intensity = 0.2, easing = "sine.inOut" } = config; useGSAP( () => { if (!enabled || !containerRef.current) return; const element = containerRef.current; const timeline = gsap3.timeline({ repeat: -1, // Infinite loop yoyo: true // Reverse on each iteration }); const maxMultiplier = Math.min(2, 1 + intensity); timeline.fromTo( element, { "--pulse-multiplier": 1 }, { "--pulse-multiplier": maxMultiplier, duration, ease: easing } ); const updateOpacity = () => { const baseOpacity = getComputedStyle(element).getPropertyValue("--base-opacity") || "1"; const pulseMultiplier = getComputedStyle(element).getPropertyValue("--pulse-multiplier") || "1"; element.style.opacity = `calc(${baseOpacity} * ${pulseMultiplier})`; }; const ticker = gsap3.ticker.add(updateOpacity); return () => { timeline.kill(); gsap3.ticker.remove(ticker); }; }, { dependencies: [enabled, duration, intensity, easing], scope: containerRef } ); return null; }; gsap3.registerPlugin(ScrollTrigger, useGSAP); var defaultStyles = { height: "var(--react-light-beam-height, 500px)", width: "var(--react-light-beam-width, 100vw)", // CRITICAL: NO transition on GSAP-controlled properties (background, opacity, mask) // Transitions would fight with GSAP's instant updates, causing visual glitches // especially when scroll direction changes transition: "none", willChange: "background, opacity", // Specific properties for better performance userSelect: "none", pointerEvents: "none", contain: "layout style paint", // CSS containment for better performance WebkitTransition: "none", WebkitUserSelect: "none", MozUserSelect: "none" }; var LightBeam = ({ className, style, colorLightmode = "rgba(0,0,0, 0.5)", colorDarkmode = "rgba(255, 255, 255, 0.5)", maskLightByProgress = false, fullWidth = 1, // Default to full width range invert = false, id = void 0, onLoaded = void 0, scrollElement, disableDefaultStyles = false, scrollStart = "top bottom", scrollEnd = "top top", dustParticles = { enabled: false }, mist = { enabled: false }, pulse = { enabled: false } }) => { const elementRef = useRef(null); const { isDarkmode } = useIsDarkmode(); const chosenColor = isDarkmode ? colorDarkmode : colorLightmode; const colorRef = useRef(chosenColor); const invertRef = useRef(invert); const maskByProgressRef = useRef(maskLightByProgress); const scrollTriggerRef = useRef(null); useEffect(() => { colorRef.current = chosenColor; if (elementRef.current) { elementRef.current.style.setProperty("--beam-color", chosenColor); } }, [chosenColor, colorLightmode, colorDarkmode]); useEffect(() => { const prevInvert = invertRef.current; invertRef.current = invert; if (prevInvert !== invert && scrollTriggerRef.current && elementRef.current) { const st = scrollTriggerRef.current; elementRef.current; st.refresh(); } }, [invert]); useEffect(() => { const prevMaskByProgress = maskByProgressRef.current; maskByProgressRef.current = maskLightByProgress; if (prevMaskByProgress !== maskLightByProgress && elementRef.current) { const element = elementRef.current; if (maskLightByProgress) { element.style.setProperty("--beam-mask-stop", "50%"); element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`; element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`; } else { element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`; element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`; } if (scrollTriggerRef.current) { scrollTriggerRef.current.refresh(); } } }, [maskLightByProgress]); useEffect(() => { onLoaded && onLoaded(); }, []); useGSAP( () => { const element = elementRef.current; if (!element || typeof window === "undefined") return; const opacityMin = 0.839322; const opacityRange = 0.160678; const updateColorVar = (color) => { element.style.setProperty("--beam-color", color); }; const initGradientStructure = (color) => { updateColorVar(color); const baseGradient = `conic-gradient(from 90deg at var(--beam-left-pos) 0%, var(--beam-color), transparent 180deg) 0% 0% / 50% var(--beam-left-size) no-repeat, conic-gradient(from 270deg at var(--beam-right-pos) 0%, transparent 180deg, var(--beam-color)) 100% 0% / 50% 100% no-repeat`; element.style.background = baseGradient; if (maskByProgressRef.current) { element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`; element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`; } else { element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`; element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`; } }; const adjustedFullWidth = 1 - fullWidth; const calculateProgress = (rawProgress) => { const normalizedPosition = Math.max( adjustedFullWidth, // Floor value (1 - fullWidth) Math.min(1, 1 - rawProgress) // Inverted GSAP progress ); return invertRef.current ? normalizedPosition : 1 - normalizedPosition; }; const scroller = scrollElement ? scrollElement : void 0; const applyProgressState = (progress) => { const leftPos = 90 - progress * 90; const rightPos = 10 + progress * 90; const leftSize = 150 - progress * 50; const baseOpacity = opacityMin + opacityRange * progress; const maskStop = maskByProgressRef.current ? 50 + progress * 45 : void 0; const cssProps = { "--beam-left-pos": `${leftPos}%`, "--beam-right-pos": `${rightPos}%`, "--beam-left-size": `${leftSize}%`, "--base-opacity": baseOpacity }; if (maskStop !== void 0) { cssProps["--beam-mask-stop"] = `${maskStop}%`; } if (!pulse.enabled) { cssProps.opacity = baseOpacity; } gsap3.set(element, cssProps); }; initGradientStructure(colorRef.current); const st = ScrollTrigger.create({ trigger: element, start: scrollStart, // When to start the animation end: scrollEnd, // When to end the animation scroller, scrub: 0.15, // Fast catch-up (300ms) for responsive scroll without jitter onUpdate: (self) => { const progress = calculateProgress(self.progress); applyProgressState(progress); }, onRefresh: (self) => { const progress = calculateProgress(self.progress); applyProgressState(progress); } }); scrollTriggerRef.current = st; const initialProgress = calculateProgress(st.progress); applyProgressState(initialProgress); const refreshTimeout = setTimeout(() => { ScrollTrigger.refresh(); }, 100); return () => { st.kill(); clearTimeout(refreshTimeout); }; }, { // CRITICAL: Use refs for frequently changing values! // colorRef, invertRef, maskByProgressRef allow updates without recreating ScrollTrigger // This prevents visual glitches when these values change mid-scroll // Only include values that affect ScrollTrigger's position/range calculations dependencies: [ fullWidth, // Affects progress range calculation scrollElement, // Affects which element to watch scrollStart, // Affects when animation starts scrollEnd // Affects when animation ends ], scope: elementRef } ); const combinedClassName = `react-light-beam ${className || ""}`.trim(); const finalStyles = disableDefaultStyles ? { // No default styles, only user styles willChange: "background, opacity", contain: "layout style paint", ...style // User styles override } : { // Merge default styles with user styles ...defaultStyles, ...style // User styles override everything }; return /* @__PURE__ */ jsxs( "div", { ref: elementRef, className: combinedClassName, style: finalStyles, ...id ? { id } : {}, children: [ dustParticles.enabled && /* @__PURE__ */ jsx(DustParticles, { config: dustParticles, beamColor: chosenColor }), mist.enabled && /* @__PURE__ */ jsx(MistEffect, { config: mist, beamColor: chosenColor }), pulse.enabled && /* @__PURE__ */ jsx(PulseEffect, { config: pulse, containerRef: elementRef }) ] } ); }; export { LightBeam }; //# sourceMappingURL=index.mjs.map