11"use client" ;
22
3- import { useSyncExternalStore } from "react" ;
4- import { motion } from "framer-motion" ;
3+ import { useRef , useSyncExternalStore } from "react" ;
4+ import { useAnimationFrame } from "framer-motion" ;
55
66interface Particle {
77 id : number ;
@@ -23,21 +23,28 @@ const PARTICLE_COLORS = [
2323 "rgba(255, 255, 255, 0.2)" ,
2424] as const ;
2525
26+ const ORB_COLORS = [
27+ "rgba(147, 71, 255, 0.55)" ,
28+ "rgba(2, 255, 226, 0.4)" ,
29+ ] as const ;
30+
31+ const LITE_QUERY = "(max-width: 767px), (prefers-reduced-motion: reduce)" ;
32+
2633let cachedParticles : Particle [ ] | null = null ;
2734
2835function getClientParticles ( ) : Particle [ ] {
2936 if ( cachedParticles ) return cachedParticles ;
3037
31- cachedParticles = Array . from ( { length : 15 } , ( _ , i ) => {
38+ cachedParticles = Array . from ( { length : 8 } , ( _ , i ) => {
3239 const randomColorIndex = Math . floor ( Math . random ( ) * PARTICLE_COLORS . length ) ;
3340 return {
3441 id : i ,
3542 x : Math . random ( ) * 100 ,
3643 y : Math . random ( ) * 100 ,
37- size : Math . random ( ) * 8 + 4 ,
44+ size : Math . random ( ) * 6 + 3 ,
3845 color : PARTICLE_COLORS [ randomColorIndex ] ! ,
39- opacity : Math . random ( ) * 0.5 + 0.2 ,
40- speed : Math . random ( ) * 0.5 + 0.2 ,
46+ opacity : Math . random ( ) * 0.4 + 0.15 ,
47+ speed : Math . random ( ) * 0.4 + 0.2 ,
4148 direction : Math . random ( ) * Math . PI * 2 ,
4249 } ;
4350 } ) ;
@@ -49,30 +56,110 @@ function subscribeParticles() {
4956 return ( ) => { } ;
5057}
5158
52- const FloatingParticles = ( ) => {
53- const particles = useSyncExternalStore (
54- subscribeParticles ,
55- getClientParticles ,
56- ( ) => EMPTY_PARTICLES ,
57- ) ;
59+ function subscribeLiteMode ( onStoreChange : ( ) => void ) {
60+ const media = window . matchMedia ( LITE_QUERY ) ;
61+ media . addEventListener ( "change" , onStoreChange ) ;
62+ return ( ) => media . removeEventListener ( "change" , onStoreChange ) ;
63+ }
5864
59- if ( particles . length === 0 ) {
60- return (
65+ function getLiteModeSnapshot ( ) {
66+ return window . matchMedia ( LITE_QUERY ) . matches ;
67+ }
68+
69+ function wave01 ( timeMs : number , durationSec : number , delaySec : number ) {
70+ const elapsed = Math . max ( 0 , timeMs / 1000 - delaySec ) ;
71+ const cycle = ( elapsed % durationSec ) / durationSec ;
72+ return ( 1 - Math . cos ( cycle * Math . PI * 2 ) ) / 2 ;
73+ }
74+
75+ function LiteAtmosphere ( ) {
76+ return (
77+ < div
78+ className = "pointer-events-none absolute inset-0 overflow-hidden"
79+ style = { { zIndex : 1 } }
80+ aria-hidden
81+ >
6182 < div
62- className = "pointer-events-none absolute inset-0 overflow-hidden"
63- style = { { zIndex : 1 } }
83+ className = "absolute left-1/4 top-1/3 h-24 w-24 rounded-full opacity-40"
84+ style = { {
85+ background :
86+ "radial-gradient(circle, rgba(147, 71, 255, 0.25) 0%, transparent 70%)" ,
87+ filter : "blur(12px)" ,
88+ } }
6489 />
65- ) ;
66- }
90+ < div
91+ className = "absolute bottom-1/3 right-1/4 h-20 w-20 rounded-full opacity-35"
92+ style = { {
93+ background :
94+ "radial-gradient(circle, rgba(2, 255, 226, 0.2) 0%, transparent 70%)" ,
95+ filter : "blur(10px)" ,
96+ } }
97+ />
98+ </ div >
99+ ) ;
100+ }
101+
102+ function DesktopParticles ( { particles } : { particles : Particle [ ] } ) {
103+ const particleRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
104+ const orbRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
105+ const ambientARef = useRef < HTMLDivElement | null > ( null ) ;
106+ const ambientBRef = useRef < HTMLDivElement | null > ( null ) ;
107+
108+ useAnimationFrame ( ( time ) => {
109+ if ( typeof document !== "undefined" && document . hidden ) return ;
110+
111+ for ( let i = 0 ; i < particles . length ; i ++ ) {
112+ const el = particleRefs . current [ i ] ;
113+ const particle = particles [ i ] ;
114+ if ( ! el || ! particle ) continue ;
115+
116+ const w = wave01 ( time , 10 + particle . speed * 4 , particle . id * 0.15 ) ;
117+ const dx = Math . cos ( particle . direction ) * 24 * w ;
118+ const dy = Math . sin ( particle . direction ) * 24 * w ;
119+ el . style . transform = `translate3d(${ dx } px, ${ dy } px, 0)` ;
120+ el . style . opacity = String ( particle . opacity * ( 1 - 0.45 * w ) ) ;
121+ }
122+
123+ for ( let i = 0 ; i < 3 ; i ++ ) {
124+ const el = orbRefs . current [ i ] ;
125+ if ( ! el ) continue ;
126+
127+ const w = wave01 ( time , 14 + i * 2 , 0 ) ;
128+ const dx = ( i * 20 - 40 ) * w ;
129+ const dy = ( i * 14 - 24 ) * w ;
130+ el . style . transform = `translate3d(${ dx } px, ${ dy } px, 0)` ;
131+ el . style . opacity = String ( 0.5 + 0.35 * w ) ;
132+ }
133+
134+ const ambientA = ambientARef . current ;
135+ if ( ambientA ) {
136+ const w = wave01 ( time , 14 , 0 ) ;
137+ const scale = 1 + 0.2 * w ;
138+ ambientA . style . transform = `scale(${ scale } )` ;
139+ ambientA . style . opacity = String ( 0.35 + 0.2 * w ) ;
140+ }
141+
142+ const ambientB = ambientBRef . current ;
143+ if ( ambientB ) {
144+ const w = wave01 ( time , 12 , 0 ) ;
145+ const scale = 1.15 - 0.15 * w ;
146+ ambientB . style . transform = `scale(${ scale } )` ;
147+ ambientB . style . opacity = String ( 0.4 + 0.2 * w ) ;
148+ }
149+ } ) ;
67150
68151 return (
69152 < div
70153 className = "pointer-events-none absolute inset-0 overflow-hidden"
71154 style = { { zIndex : 1 } }
155+ aria-hidden
72156 >
73- { particles . map ( ( particle ) => (
74- < motion . div
157+ { particles . map ( ( particle , index ) => (
158+ < div
75159 key = { particle . id }
160+ ref = { ( node ) => {
161+ particleRefs . current [ index ] = node ;
162+ } }
76163 className = "absolute rounded-full"
77164 style = { {
78165 width : particle . size ,
@@ -81,134 +168,83 @@ const FloatingParticles = () => {
81168 left : `${ particle . x } %` ,
82169 top : `${ particle . y } %` ,
83170 opacity : particle . opacity ,
171+ willChange : "transform, opacity" ,
84172 } }
85- animate = { {
86- x : [ 0 , Math . cos ( particle . direction ) * 30 , 0 ] ,
87- y : [ 0 , Math . sin ( particle . direction ) * 30 , 0 ] ,
88- scale : [ 1 , 1.2 , 1 ] ,
89- opacity : [
90- particle . opacity ,
91- particle . opacity * 0.5 ,
92- particle . opacity ,
93- ] ,
94- } }
95- transition = { {
96- duration : 8 + particle . speed * 4 ,
97- repeat : Infinity ,
98- ease : "easeInOut" ,
99- delay : particle . id * 0.2 ,
100- } }
101- >
102- < motion . div
103- className = "absolute inset-0 rounded-full"
104- style = { {
105- background : `radial-gradient(circle, ${ particle . color } 0%, transparent 70%)` ,
106- filter : "blur(2px)" ,
107- } }
108- animate = { {
109- scale : [ 1 , 1.5 , 1 ] ,
110- opacity : [ 0.8 , 0.3 , 0.8 ] ,
111- } }
112- transition = { {
113- duration : 4 + particle . speed * 2 ,
114- repeat : Infinity ,
115- ease : "easeInOut" ,
116- delay : particle . id * 0.1 ,
117- } }
118- />
119- </ motion . div >
173+ />
120174 ) ) }
121175
122- { Array . from ( { length : 8 } , ( _ , i ) => (
123- < motion . div
176+ { Array . from ( { length : 3 } , ( _ , i ) => (
177+ < div
124178 key = { `orb-${ i } ` }
125- className = "absolute"
126- style = { {
127- left : `${ 20 + i * 10 } %` ,
128- top : `${ 30 + ( i % 3 ) * 20 } %` ,
179+ ref = { ( node ) => {
180+ orbRefs . current [ i ] = node ;
129181 } }
130- animate = { {
131- x : [ 0 , i * 30 - 120 , i * 30 + 120 , 0 ] ,
132- y : [ 0 , i * 20 - 80 , i * 20 + 80 , 0 ] ,
133- rotate : [ 0 , 360 ] ,
134- scale : [ 1 , 1.1 , 1 ] ,
135- } }
136- transition = { {
137- x : {
138- duration : 15 + i * 2 ,
139- repeat : Infinity ,
140- ease : "easeInOut" ,
141- } ,
142- y : {
143- duration : 12 + i * 2 ,
144- repeat : Infinity ,
145- ease : "easeInOut" ,
146- } ,
147- rotate : {
148- duration : 20 + i * 2 ,
149- repeat : Infinity ,
150- ease : "linear" ,
151- } ,
152- scale : {
153- duration : 6 + i ,
154- repeat : Infinity ,
155- ease : "easeInOut" ,
156- } ,
182+ className = "absolute h-2.5 w-2.5 rounded-full"
183+ style = { {
184+ left : `${ 25 + i * 22 } %` ,
185+ top : `${ 28 + ( i % 3 ) * 18 } %` ,
186+ background : `radial-gradient(circle, ${ ORB_COLORS [ i % 2 ] } 0%, transparent 70%)` ,
187+ opacity : 0.5 ,
188+ willChange : "transform, opacity" ,
157189 } }
158- >
159- < div
160- className = "h-3 w-3 rounded-full"
161- style = { {
162- background : `radial-gradient(circle, ${
163- [ "rgba(147, 71, 255, 0.6)" , "rgba(2, 255, 226, 0.4)" ] [ i % 2 ]
164- } 0%, transparent 70%)`,
165- filter : "blur(1px)" ,
166- } }
167- />
168- </ motion . div >
190+ />
169191 ) ) }
170192
171- < motion . div
172- className = "absolute left-1/4 top-1/3 h-20 w-20 rounded-full"
193+ < div
194+ ref = { ambientARef }
195+ className = "absolute left-1/4 top-1/3 h-16 w-16 rounded-full"
173196 style = { {
174197 background :
175- "radial-gradient(circle, rgba(147, 71, 255, 0.1) 0%, transparent 70%)" ,
176- filter : "blur(10px)" ,
177- } }
178- animate = { {
179- scale : [ 1 , 1.3 , 1 ] ,
180- opacity : [ 0.3 , 0.6 , 0.3 ] ,
181- x : [ 0 , 20 , 0 ] ,
182- y : [ 0 , - 10 , 0 ] ,
183- } }
184- transition = { {
185- duration : 12 ,
186- repeat : Infinity ,
187- ease : "easeInOut" ,
198+ "radial-gradient(circle, rgba(147, 71, 255, 0.12) 0%, transparent 70%)" ,
199+ filter : "blur(8px)" ,
200+ opacity : 0.35 ,
201+ willChange : "transform, opacity" ,
188202 } }
189203 />
190204
191- < motion . div
192- className = "absolute right-1/4 top-2/3 h-16 w-16 rounded-full"
205+ < div
206+ ref = { ambientBRef }
207+ className = "absolute right-1/4 top-2/3 h-14 w-14 rounded-full"
193208 style = { {
194209 background :
195- "radial-gradient(circle, rgba(2, 255, 226, 0.15 ) 0%, transparent 70%)" ,
210+ "radial-gradient(circle, rgba(2, 255, 226, 0.14 ) 0%, transparent 70%)" ,
196211 filter : "blur(8px)" ,
197- } }
198- animate = { {
199- scale : [ 1.2 , 1 , 1.2 ] ,
200- opacity : [ 0.4 , 0.7 , 0.4 ] ,
201- x : [ 0 , - 15 , 0 ] ,
202- y : [ 0 , 15 , 0 ] ,
203- } }
204- transition = { {
205- duration : 10 ,
206- repeat : Infinity ,
207- ease : "easeInOut" ,
212+ opacity : 0.4 ,
213+ willChange : "transform, opacity" ,
208214 } }
209215 />
210216 </ div >
211217 ) ;
218+ }
219+
220+ const FloatingParticles = ( ) => {
221+ const isLite = useSyncExternalStore (
222+ subscribeLiteMode ,
223+ getLiteModeSnapshot ,
224+ ( ) => true ,
225+ ) ;
226+
227+ const particles = useSyncExternalStore (
228+ subscribeParticles ,
229+ getClientParticles ,
230+ ( ) => EMPTY_PARTICLES ,
231+ ) ;
232+
233+ if ( isLite ) {
234+ return < LiteAtmosphere /> ;
235+ }
236+
237+ if ( particles . length === 0 ) {
238+ return (
239+ < div
240+ className = "pointer-events-none absolute inset-0 overflow-hidden"
241+ style = { { zIndex : 1 } }
242+ aria-hidden
243+ />
244+ ) ;
245+ }
246+
247+ return < DesktopParticles particles = { particles } /> ;
212248} ;
213249
214250export default FloatingParticles ;
0 commit comments