@stianlarsen/react-light-beam 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,456 +1,144 @@
1
1
  "use client";
2
- 'use strict';
3
2
 
4
- var gsap3 = require('gsap');
5
- var ScrollTrigger = require('gsap/ScrollTrigger');
6
- var react = require('@gsap/react');
7
- var react$1 = require('react');
8
- var jsxRuntime = require('react/jsx-runtime');
3
+ // src/index.tsx
4
+ import { motion, useMotionValue, useTransform } from "framer-motion";
5
+ import { useEffect as useEffect2, useRef } from "react";
9
6
 
10
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
+ // #style-inject:#style-inject
8
+ function styleInject(css, { insertAt } = {}) {
9
+ if (!css || typeof document === "undefined") return;
10
+ const head = document.head || document.getElementsByTagName("head")[0];
11
+ const style = document.createElement("style");
12
+ style.type = "text/css";
13
+ if (insertAt === "top") {
14
+ if (head.firstChild) {
15
+ head.insertBefore(style, head.firstChild);
16
+ } else {
17
+ head.appendChild(style);
18
+ }
19
+ } else {
20
+ head.appendChild(style);
21
+ }
22
+ if (style.styleSheet) {
23
+ style.styleSheet.cssText = css;
24
+ } else {
25
+ style.appendChild(document.createTextNode(css));
26
+ }
27
+ }
11
28
 
12
- var gsap3__default = /*#__PURE__*/_interopDefault(gsap3);
29
+ // src/css/lightBeam.css
30
+ styleInject(".react__light__beam {\n height: 500px;\n width: 100vw;\n transition: all 0.25s ease;\n will-change: all;\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n pointer-events: none;\n -webkit-transition: all 0.25s ease;\n}\n");
13
31
 
32
+ // src/hooks/useDarkmode.tsx
33
+ import { useEffect, useState } from "react";
14
34
  var useIsDarkmode = () => {
15
- const [isDarkmode, setIsDarkmodeActive] = react$1.useState(false);
16
- react$1.useEffect(() => {
35
+ const [isDarkmode, setIsDarkmodeActive] = useState(false);
36
+ useEffect(() => {
17
37
  const matchMedia = window.matchMedia("(prefers-color-scheme: dark)");
18
38
  const handleChange = () => {
19
- console.log("Darkmode match?", matchMedia.matches);
20
39
  setIsDarkmodeActive(matchMedia.matches);
21
40
  };
22
41
  setIsDarkmodeActive(matchMedia.matches);
23
42
  matchMedia.addEventListener("change", handleChange);
24
- handleChange();
25
43
  return () => {
26
44
  matchMedia.removeEventListener("change", handleChange);
27
45
  };
28
46
  }, []);
29
47
  return { isDarkmode };
30
48
  };
31
- var DustParticles = ({ config, beamColor }) => {
32
- const {
33
- enabled = false,
34
- count = 30,
35
- speed = 1,
36
- sizeRange = [1, 3],
37
- opacityRange = [0.2, 0.6],
38
- color
39
- } = config;
40
- const particles = react$1.useMemo(() => {
41
- if (!enabled) return [];
42
- return Array.from({ length: count }, (_, i) => {
43
- const x = Math.random() * 100;
44
- const y = Math.random() * 100;
45
- const size = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]);
46
- const opacity = opacityRange[0] + Math.random() * (opacityRange[1] - opacityRange[0]);
47
- const duration = (3 + Math.random() * 4) / speed;
48
- const delay = Math.random() * duration;
49
- return {
50
- id: `dust-${i}`,
51
- x,
52
- y,
53
- size,
54
- opacity,
55
- duration,
56
- delay
57
- };
58
- });
59
- }, [enabled, count, sizeRange, opacityRange, speed]);
60
- react.useGSAP(
61
- () => {
62
- if (!enabled || particles.length === 0) return;
63
- const timelines = [];
64
- particles.forEach((particle) => {
65
- const element = document.getElementById(particle.id);
66
- if (!element) return;
67
- const tl = gsap3__default.default.timeline({
68
- repeat: -1,
69
- yoyo: true,
70
- delay: particle.delay
71
- });
72
- tl.to(element, {
73
- y: `-=${20 + Math.random() * 30}`,
74
- // Float upward 20-50px
75
- x: `+=${Math.random() * 20 - 10}`,
76
- // Slight horizontal drift ±10px
77
- opacity: particle.opacity * 0.5,
78
- // Fade slightly
79
- duration: particle.duration,
80
- ease: "sine.inOut"
81
- });
82
- timelines.push(tl);
83
- });
84
- return () => {
85
- timelines.forEach((tl) => tl.kill());
86
- };
87
- },
88
- {
89
- dependencies: [particles, enabled]
90
- }
91
- );
92
- if (!enabled) return null;
93
- const particleColor = color || beamColor;
94
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: particles.map((particle) => /* @__PURE__ */ jsxRuntime.jsx(
95
- "div",
96
- {
97
- id: particle.id,
98
- style: {
99
- position: "absolute",
100
- left: `${particle.x}%`,
101
- top: `${particle.y}%`,
102
- width: `${particle.size}px`,
103
- height: `${particle.size}px`,
104
- borderRadius: "50%",
105
- backgroundColor: particleColor,
106
- opacity: particle.opacity,
107
- pointerEvents: "none",
108
- willChange: "transform, opacity"
109
- }
110
- },
111
- particle.id
112
- )) });
113
- };
114
- var MistEffect = ({ config, beamColor }) => {
115
- const {
116
- enabled = false,
117
- intensity = 0.3,
118
- speed = 1,
119
- layers = 2
120
- } = config;
121
- const mistLayers = react$1.useMemo(() => {
122
- if (!enabled) return [];
123
- return Array.from({ length: layers }, (_, i) => {
124
- const layerOpacity = intensity * 0.6 / (i + 1);
125
- const duration = (8 + i * 3) / speed;
126
- const delay = i * 1.5 / speed;
127
- const scale = 1 + i * 0.2;
128
- return {
129
- id: `mist-layer-${i}`,
130
- opacity: layerOpacity,
131
- duration,
132
- delay,
133
- scale
134
- };
135
- });
136
- }, [enabled, intensity, speed, layers]);
137
- react.useGSAP(
138
- () => {
139
- if (!enabled || mistLayers.length === 0) return;
140
- const timelines = [];
141
- mistLayers.forEach((layer) => {
142
- const element = document.getElementById(layer.id);
143
- if (!element) return;
144
- const tl = gsap3__default.default.timeline({
145
- repeat: -1,
146
- yoyo: false
147
- });
148
- tl.fromTo(
149
- element,
150
- {
151
- x: "-100%",
152
- opacity: 0
153
- },
154
- {
155
- x: "100%",
156
- opacity: layer.opacity,
157
- duration: layer.duration,
158
- ease: "none",
159
- delay: layer.delay
160
- }
161
- ).to(element, {
162
- opacity: 0,
163
- duration: layer.duration * 0.2,
164
- ease: "power1.in"
165
- });
166
- timelines.push(tl);
167
- });
168
- return () => {
169
- timelines.forEach((tl) => tl.kill());
170
- };
171
- },
172
- {
173
- dependencies: [mistLayers, enabled]
174
- }
175
- );
176
- if (!enabled) return null;
177
- const mistColor = beamColor.replace(/[\d.]+\)$/g, `${intensity})`);
178
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: mistLayers.map((layer) => /* @__PURE__ */ jsxRuntime.jsx(
179
- "div",
180
- {
181
- id: layer.id,
182
- style: {
183
- position: "absolute",
184
- top: 0,
185
- left: 0,
186
- width: "100%",
187
- height: "100%",
188
- background: `radial-gradient(ellipse 120% 80% at 50% 20%, ${mistColor}, transparent 70%)`,
189
- opacity: 0,
190
- pointerEvents: "none",
191
- willChange: "transform, opacity",
192
- transform: `scale(${layer.scale})`,
193
- filter: "blur(40px)"
194
- }
195
- },
196
- layer.id
197
- )) });
198
- };
199
- var PulseEffect = ({ config, containerRef }) => {
200
- const {
201
- enabled = false,
202
- duration = 2,
203
- intensity = 0.2,
204
- easing = "sine.inOut"
205
- } = config;
206
- react.useGSAP(
207
- () => {
208
- if (!enabled || !containerRef.current) return;
209
- const element = containerRef.current;
210
- const timeline = gsap3__default.default.timeline({
211
- repeat: -1,
212
- // Infinite loop
213
- yoyo: true
214
- // Reverse on each iteration
215
- });
216
- const maxMultiplier = Math.min(2, 1 + intensity);
217
- timeline.fromTo(
218
- element,
219
- {
220
- "--pulse-multiplier": 1
221
- },
222
- {
223
- "--pulse-multiplier": maxMultiplier,
224
- duration,
225
- ease: easing
226
- }
227
- );
228
- const updateOpacity = () => {
229
- const baseOpacity = getComputedStyle(element).getPropertyValue("--base-opacity") || "1";
230
- const pulseMultiplier = getComputedStyle(element).getPropertyValue("--pulse-multiplier") || "1";
231
- element.style.opacity = `calc(${baseOpacity} * ${pulseMultiplier})`;
232
- };
233
- const ticker = gsap3__default.default.ticker.add(updateOpacity);
234
- return () => {
235
- timeline.kill();
236
- gsap3__default.default.ticker.remove(ticker);
237
- };
238
- },
239
- {
240
- dependencies: [enabled, duration, intensity, easing],
241
- scope: containerRef
242
- }
243
- );
244
- return null;
245
- };
246
- gsap3__default.default.registerPlugin(ScrollTrigger.ScrollTrigger, react.useGSAP);
247
- var defaultStyles = {
248
- height: "var(--react-light-beam-height, 500px)",
249
- width: "var(--react-light-beam-width, 100vw)",
250
- // CRITICAL: NO transition on GSAP-controlled properties (background, opacity, mask)
251
- // Transitions would fight with GSAP's instant updates, causing visual glitches
252
- // especially when scroll direction changes
253
- transition: "none",
254
- willChange: "background, opacity",
255
- // Specific properties for better performance
256
- userSelect: "none",
257
- pointerEvents: "none",
258
- contain: "layout style paint",
259
- // CSS containment for better performance
260
- WebkitTransition: "none",
261
- WebkitUserSelect: "none",
262
- MozUserSelect: "none"
263
- };
49
+
50
+ // src/index.tsx
51
+ import { jsx } from "react/jsx-runtime";
264
52
  var LightBeam = ({
265
53
  className,
266
- style,
267
54
  colorLightmode = "rgba(0,0,0, 0.5)",
268
55
  colorDarkmode = "rgba(255, 255, 255, 0.5)",
269
56
  maskLightByProgress = false,
270
57
  fullWidth = 1,
271
- // Default to full width range
58
+ // Default to full width
272
59
  invert = false,
273
60
  id = void 0,
274
61
  onLoaded = void 0,
275
- scrollElement,
276
- disableDefaultStyles = false,
277
- scrollStart = "top bottom",
278
- scrollEnd = "top top",
279
- dustParticles = { enabled: false },
280
- mist = { enabled: false },
281
- pulse = { enabled: false }
62
+ scrollElement
63
+ // Add this line
282
64
  }) => {
283
- const elementRef = react$1.useRef(null);
65
+ const elementRef = useRef(null);
66
+ const inViewProgress = useMotionValue(0);
67
+ const opacity = useMotionValue(0.839322);
284
68
  const { isDarkmode } = useIsDarkmode();
285
69
  const chosenColor = isDarkmode ? colorDarkmode : colorLightmode;
286
- const colorRef = react$1.useRef(chosenColor);
287
- const invertRef = react$1.useRef(invert);
288
- const maskByProgressRef = react$1.useRef(maskLightByProgress);
289
- const scrollTriggerRef = react$1.useRef(null);
290
- react$1.useEffect(() => {
291
- colorRef.current = chosenColor;
292
- if (elementRef.current) {
293
- elementRef.current.style.setProperty("--beam-color", chosenColor);
294
- }
295
- }, [chosenColor, colorLightmode, colorDarkmode]);
296
- react$1.useEffect(() => {
297
- const prevInvert = invertRef.current;
298
- invertRef.current = invert;
299
- if (prevInvert !== invert && scrollTriggerRef.current && elementRef.current) {
300
- const st = scrollTriggerRef.current;
301
- elementRef.current;
302
- st.refresh();
303
- }
304
- }, [invert]);
305
- react$1.useEffect(() => {
306
- const prevMaskByProgress = maskByProgressRef.current;
307
- maskByProgressRef.current = maskLightByProgress;
308
- if (prevMaskByProgress !== maskLightByProgress && elementRef.current) {
309
- const element = elementRef.current;
310
- if (maskLightByProgress) {
311
- element.style.setProperty("--beam-mask-stop", "50%");
312
- element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`;
313
- element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`;
314
- } else {
315
- element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`;
316
- element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`;
317
- }
318
- if (scrollTriggerRef.current) {
319
- scrollTriggerRef.current.refresh();
320
- }
321
- }
322
- }, [maskLightByProgress]);
323
- react$1.useEffect(() => {
70
+ useEffect2(() => {
324
71
  onLoaded && onLoaded();
325
72
  }, []);
326
- react.useGSAP(
327
- () => {
328
- const element = elementRef.current;
329
- if (!element || typeof window === "undefined") return;
330
- const opacityMin = 0.839322;
331
- const opacityRange = 0.160678;
332
- const updateColorVar = (color) => {
333
- element.style.setProperty("--beam-color", color);
334
- };
335
- const initGradientStructure = (color) => {
336
- updateColorVar(color);
337
- 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`;
338
- element.style.background = baseGradient;
339
- if (maskByProgressRef.current) {
340
- element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`;
341
- element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 0%, transparent var(--beam-mask-stop))`;
342
- } else {
343
- element.style.maskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`;
344
- element.style.webkitMaskImage = `linear-gradient(to bottom, var(--beam-color) 25%, transparent 95%)`;
345
- }
346
- };
347
- const adjustedFullWidth = 1 - fullWidth;
348
- const calculateProgress = (rawProgress) => {
349
- const normalizedPosition = Math.max(
350
- adjustedFullWidth,
351
- // Floor value (1 - fullWidth)
352
- Math.min(1, 1 - rawProgress)
353
- // Inverted GSAP progress
354
- );
355
- return invertRef.current ? normalizedPosition : 1 - normalizedPosition;
356
- };
357
- const scroller = scrollElement ? scrollElement : void 0;
358
- const applyProgressState = (progress) => {
359
- const leftPos = 90 - progress * 90;
360
- const rightPos = 10 + progress * 90;
361
- const leftSize = 150 - progress * 50;
362
- const baseOpacity = opacityMin + opacityRange * progress;
363
- const maskStop = maskByProgressRef.current ? 50 + progress * 45 : void 0;
364
- const cssProps = {
365
- "--beam-left-pos": `${leftPos}%`,
366
- "--beam-right-pos": `${rightPos}%`,
367
- "--beam-left-size": `${leftSize}%`,
368
- "--base-opacity": baseOpacity
369
- };
370
- if (maskStop !== void 0) {
371
- cssProps["--beam-mask-stop"] = `${maskStop}%`;
372
- }
373
- if (!pulse.enabled) {
374
- cssProps.opacity = baseOpacity;
73
+ useEffect2(() => {
74
+ if (typeof window !== "undefined") {
75
+ const handleScroll = () => {
76
+ if (elementRef.current) {
77
+ const rect = elementRef.current.getBoundingClientRect();
78
+ const windowHeight = window.innerHeight;
79
+ const adjustedFullWidth = 1 - fullWidth;
80
+ const progress = invert ? 0 + Math.max(adjustedFullWidth, Math.min(1, rect.top / windowHeight)) : 1 - Math.max(adjustedFullWidth, Math.min(1, rect.top / windowHeight));
81
+ inViewProgress.set(progress);
82
+ opacity.set(0.839322 + (1 - 0.839322) * progress);
375
83
  }
376
- gsap3__default.default.set(element, cssProps);
377
84
  };
378
- initGradientStructure(colorRef.current);
379
- const st = ScrollTrigger.ScrollTrigger.create({
380
- trigger: element,
381
- start: scrollStart,
382
- // When to start the animation
383
- end: scrollEnd,
384
- // When to end the animation
385
- scroller,
386
- scrub: 0.15,
387
- // Fast catch-up (300ms) for responsive scroll without jitter
388
- onUpdate: (self) => {
389
- const progress = calculateProgress(self.progress);
390
- applyProgressState(progress);
391
- },
392
- onRefresh: (self) => {
393
- const progress = calculateProgress(self.progress);
394
- applyProgressState(progress);
395
- }
396
- });
397
- scrollTriggerRef.current = st;
398
- const initialProgress = calculateProgress(st.progress);
399
- applyProgressState(initialProgress);
400
- const refreshTimeout = setTimeout(() => {
401
- ScrollTrigger.ScrollTrigger.refresh();
402
- }, 100);
85
+ const handleScrollThrottled = throttle(handleScroll);
86
+ const target = scrollElement || window;
87
+ target.addEventListener("scroll", handleScrollThrottled);
88
+ window.addEventListener("resize", handleScrollThrottled);
89
+ handleScroll();
403
90
  return () => {
404
- st.kill();
405
- clearTimeout(refreshTimeout);
91
+ target.removeEventListener("scroll", handleScrollThrottled);
92
+ window.removeEventListener("resize", handleScrollThrottled);
406
93
  };
407
- },
408
- {
409
- // CRITICAL: Use refs for frequently changing values!
410
- // colorRef, invertRef, maskByProgressRef allow updates without recreating ScrollTrigger
411
- // This prevents visual glitches when these values change mid-scroll
412
- // Only include values that affect ScrollTrigger's position/range calculations
413
- dependencies: [
414
- fullWidth,
415
- // Affects progress range calculation
416
- scrollElement,
417
- // Affects which element to watch
418
- scrollStart,
419
- // Affects when animation starts
420
- scrollEnd
421
- // Affects when animation ends
422
- ],
423
- scope: elementRef
424
94
  }
95
+ }, [inViewProgress, opacity, scrollElement]);
96
+ const backgroundPosition = useTransform(
97
+ inViewProgress,
98
+ [0, 1],
99
+ [
100
+ `conic-gradient(from 90deg at 90% 0%, ${chosenColor}, transparent 180deg) 0% 0% / 50% 150% no-repeat, conic-gradient(from 270deg at 10% 0%, transparent 180deg, ${chosenColor}) 100% 0% / 50% 100% no-repeat`,
101
+ `conic-gradient(from 90deg at 0% 0%, ${chosenColor}, transparent 180deg) 0% 0% / 50% 100% no-repeat, conic-gradient(from 270deg at 100% 0%, transparent 180deg, ${chosenColor}) 100% 0% / 50% 100% no-repeat`
102
+ ]
425
103
  );
426
- const combinedClassName = `react-light-beam ${className || ""}`.trim();
427
- const finalStyles = disableDefaultStyles ? {
428
- // No default styles, only user styles
429
- willChange: "background, opacity",
430
- contain: "layout style paint",
431
- ...style
432
- // User styles override
433
- } : {
434
- // Merge default styles with user styles
435
- ...defaultStyles,
436
- ...style
437
- // User styles override everything
438
- };
439
- return /* @__PURE__ */ jsxRuntime.jsxs(
440
- "div",
104
+ const maskImageOpacity = useTransform(
105
+ inViewProgress,
106
+ [0, 1],
107
+ [
108
+ `linear-gradient(to bottom, ${chosenColor} 0%, transparent 50%)`,
109
+ `linear-gradient(to bottom, ${chosenColor} 0%, transparent 95%)`
110
+ ]
111
+ );
112
+ const maskImage = maskLightByProgress ? maskImageOpacity : `linear-gradient(to bottom, ${chosenColor} 25%, transparent 95%)`;
113
+ return /* @__PURE__ */ jsx(
114
+ motion.div,
441
115
  {
116
+ style: {
117
+ background: backgroundPosition,
118
+ opacity,
119
+ maskImage,
120
+ WebkitMaskImage: maskImage,
121
+ willChange: "background, opacity"
122
+ },
442
123
  ref: elementRef,
443
- className: combinedClassName,
444
- style: finalStyles,
445
- ...id ? { id } : {},
446
- children: [
447
- dustParticles.enabled && /* @__PURE__ */ jsxRuntime.jsx(DustParticles, { config: dustParticles, beamColor: chosenColor }),
448
- mist.enabled && /* @__PURE__ */ jsxRuntime.jsx(MistEffect, { config: mist, beamColor: chosenColor }),
449
- pulse.enabled && /* @__PURE__ */ jsxRuntime.jsx(PulseEffect, { config: pulse, containerRef: elementRef })
450
- ]
124
+ id,
125
+ className: `lightBeam ${className} react__light__beam`
451
126
  }
452
127
  );
453
128
  };
454
-
455
- exports.LightBeam = LightBeam;
129
+ var throttle = (func) => {
130
+ let ticking = false;
131
+ return function(...args) {
132
+ if (!ticking) {
133
+ requestAnimationFrame(() => {
134
+ func.apply(this, args);
135
+ ticking = false;
136
+ });
137
+ ticking = true;
138
+ }
139
+ };
140
+ };
141
+ export {
142
+ LightBeam
143
+ };
456
144
  //# sourceMappingURL=index.js.map