Skip to content

Commit 53f58cc

Browse files
committed
floating particles use useAnimationFrame, reduce-motion, smaller screen optimizations
1 parent 62c2476 commit 53f58cc

1 file changed

Lines changed: 164 additions & 128 deletions

File tree

Lines changed: 164 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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

66
interface 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+
2633
let cachedParticles: Particle[] | null = null;
2734

2835
function 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

214250
export default FloatingParticles;

0 commit comments

Comments
 (0)