Skip to content

Commit 8c598e9

Browse files
feat: particle effect on homepage and upgrade of packages
1 parent 273ca41 commit 8c598e9

File tree

8 files changed

+2746
-1572
lines changed

8 files changed

+2746
-1572
lines changed

next.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
3+
turbopack: {
4+
root: import.meta.dirname,
5+
},
36
images: {
47
remotePatterns: [
58
{

package-lock.json

Lines changed: 2355 additions & 1522 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
"@vercel/blob": "^1.1.1",
1818
"appwrite": "^15.0.0",
1919
"dotenv": "^17.2.1",
20-
"next": "^14.2.16",
20+
"next": "^16.1.6",
2121
"node-appwrite": "^14.1.0",
22-
"react": "^18",
23-
"react-dom": "^18",
22+
"react": "^19.2.4",
23+
"react-dom": "^19.2.4",
2424
"react-google-recaptcha": "^3.1.0",
2525
"react-icons": "^5.1.0",
2626
"sharp": "^0.33.5"
@@ -34,15 +34,19 @@
3434
"@testing-library/react": "^14.2.1",
3535
"@testing-library/user-event": "^14.6.1",
3636
"@types/node": "^20",
37-
"@types/react": "^18",
38-
"@types/react-dom": "^18",
37+
"@types/react": "^19.2.10",
38+
"@types/react-dom": "^19.2.3",
3939
"@types/react-google-recaptcha": "^2.1.9",
4040
"babel-jest": "^29.7.0",
41-
"eslint": "^8",
42-
"eslint-config-next": "14.1.1",
41+
"eslint": "^9.39.2",
42+
"eslint-config-next": "^16.1.6",
4343
"jest": "^29.7.0",
4444
"jest-environment-jsdom": "^29.7.0",
4545
"prettier": "^3.5.0",
4646
"typescript": "^5"
47+
},
48+
"overrides": {
49+
"@types/react": "^19.2.10",
50+
"@types/react-dom": "^19.2.3"
4751
}
48-
}
52+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
'use client';
2+
3+
import React, { useRef, useEffect, useCallback, memo } from 'react';
4+
5+
interface Particle {
6+
x: number;
7+
y: number;
8+
targetX: number;
9+
targetY: number;
10+
vx: number;
11+
vy: number;
12+
size: number;
13+
color: string;
14+
originalX: number;
15+
originalY: number;
16+
breatheOffset: number;
17+
breatheSpeed: number;
18+
}
19+
20+
interface ParticleCanvasProps {
21+
text?: string;
22+
className?: string;
23+
}
24+
25+
const canvasStyle: React.CSSProperties = {
26+
position: 'absolute',
27+
top: 0,
28+
left: 0,
29+
width: '100%',
30+
height: '100%',
31+
};
32+
33+
function ParticleCanvas({ text = 'DSD', className }: ParticleCanvasProps) {
34+
const canvasRef = useRef<HTMLCanvasElement>(null);
35+
const particlesRef = useRef<Particle[]>([]);
36+
const mouseRef = useRef({ x: -1000, y: -1000, isActive: false });
37+
const animationRef = useRef<number>(0);
38+
const timeRef = useRef(0);
39+
40+
const generateParticlesFromText = useCallback(
41+
(width: number, height: number) => {
42+
const particles: Particle[] = [];
43+
const offscreen = document.createElement('canvas');
44+
const offCtx = offscreen.getContext('2d');
45+
if (!offCtx) return particles;
46+
47+
if (width <= 0 || height <= 0) return particles;
48+
49+
const baseFontSize = Math.min(width * 0.25, height * 0.7, 180);
50+
const fontSize = Math.max(baseFontSize, 50);
51+
offscreen.width = width;
52+
offscreen.height = height;
53+
54+
offCtx.fillStyle = 'white';
55+
offCtx.font = `bold ${fontSize}px Inter, system-ui, sans-serif`;
56+
offCtx.textAlign = 'center';
57+
offCtx.textBaseline = 'middle';
58+
offCtx.fillText(text, offscreen.width / 2, offscreen.height / 2);
59+
60+
const imageData = offCtx.getImageData(0, 0, offscreen.width, offscreen.height);
61+
const data = imageData.data;
62+
const gap = 4;
63+
64+
for (let y = 0; y < offscreen.height; y += gap) {
65+
for (let x = 0; x < offscreen.width; x += gap) {
66+
const index = (y * offscreen.width + x) * 4;
67+
const alpha = data[index + 3];
68+
69+
if (alpha > 128) {
70+
const edge = Math.random();
71+
let startX: number;
72+
let startY: number;
73+
74+
if (edge < 0.25) {
75+
startX = -50;
76+
startY = Math.random() * height;
77+
} else if (edge < 0.5) {
78+
startX = width + 50;
79+
startY = Math.random() * height;
80+
} else if (edge < 0.75) {
81+
startX = Math.random() * width;
82+
startY = -50;
83+
} else {
84+
startX = Math.random() * width;
85+
startY = height + 50;
86+
}
87+
88+
const hue = 210 + Math.random() * 30;
89+
const saturation = 80 + Math.random() * 20;
90+
const lightness = 35 + Math.random() * 25;
91+
92+
particles.push({
93+
x: startX,
94+
y: startY,
95+
targetX: x,
96+
targetY: y,
97+
originalX: x,
98+
originalY: y,
99+
vx: 0,
100+
vy: 0,
101+
size: 1.5 + Math.random() * 2,
102+
color: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
103+
breatheOffset: Math.random() * Math.PI * 2,
104+
breatheSpeed: 0.5 + Math.random() * 1,
105+
});
106+
}
107+
}
108+
}
109+
110+
return particles;
111+
},
112+
[text]
113+
);
114+
115+
useEffect(() => {
116+
const canvas = canvasRef.current;
117+
if (!canvas) return;
118+
119+
const ctx = canvas.getContext('2d');
120+
if (!ctx) return;
121+
122+
const setupCanvas = () => {
123+
const parent = canvas.parentElement;
124+
if (!parent) return;
125+
126+
const rect = parent.getBoundingClientRect();
127+
128+
if (rect.width <= 0 || rect.height <= 0) return;
129+
130+
canvas.width = rect.width;
131+
canvas.height = rect.height;
132+
canvas.style.width = `${rect.width}px`;
133+
canvas.style.height = `${rect.height}px`;
134+
135+
particlesRef.current = generateParticlesFromText(rect.width, rect.height);
136+
};
137+
138+
setupCanvas();
139+
140+
const animate = () => {
141+
if (!ctx || !canvas) return;
142+
143+
timeRef.current += 0.016;
144+
ctx.clearRect(0, 0, canvas.width, canvas.height);
145+
146+
const particles = particlesRef.current;
147+
const mouse = mouseRef.current;
148+
const time = timeRef.current;
149+
150+
for (let i = 0; i < particles.length; i++) {
151+
const p = particles[i];
152+
153+
const breatheX = Math.sin(time * p.breatheSpeed + p.breatheOffset) * 2;
154+
const breatheY = Math.cos(time * p.breatheSpeed + p.breatheOffset) * 2;
155+
156+
const dx = mouse.x - p.x;
157+
const dy = mouse.y - p.y;
158+
const distToMouse = Math.sqrt(dx * dx + dy * dy);
159+
160+
const mouseRadius = 150;
161+
162+
if (distToMouse < mouseRadius && mouse.isActive) {
163+
const force = (1 - distToMouse / mouseRadius) * 0.08;
164+
p.vx += dx * force;
165+
p.vy += dy * force;
166+
} else {
167+
const targetX = p.originalX + breatheX;
168+
const targetY = p.originalY + breatheY;
169+
const returnForce = 0.08;
170+
p.vx += (targetX - p.x) * returnForce;
171+
p.vy += (targetY - p.y) * returnForce;
172+
}
173+
174+
p.vx *= 0.85;
175+
p.vy *= 0.85;
176+
177+
p.x += p.vx;
178+
p.y += p.vy;
179+
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
180+
const glowIntensity = Math.min(speed / 3, 1);
181+
182+
if (glowIntensity > 0.1) {
183+
ctx.shadowBlur = 10 + glowIntensity * 15;
184+
ctx.shadowColor = p.color;
185+
}
186+
187+
ctx.beginPath();
188+
ctx.arc(p.x, p.y, p.size * (1 + glowIntensity * 0.5), 0, Math.PI * 2);
189+
ctx.fillStyle = p.color;
190+
ctx.fill();
191+
ctx.shadowBlur = 0;
192+
}
193+
194+
animationRef.current = requestAnimationFrame(animate);
195+
};
196+
197+
animationRef.current = requestAnimationFrame(animate);
198+
199+
const handleMouseMove = (e: MouseEvent) => {
200+
const rect = canvas.getBoundingClientRect();
201+
mouseRef.current = {
202+
x: e.clientX - rect.left,
203+
y: e.clientY - rect.top,
204+
isActive: true,
205+
};
206+
};
207+
208+
const handleMouseLeave = () => {
209+
mouseRef.current = { ...mouseRef.current, isActive: false };
210+
};
211+
212+
const handleTouchMove = (e: TouchEvent) => {
213+
e.preventDefault();
214+
const rect = canvas.getBoundingClientRect();
215+
const touch = e.touches[0];
216+
mouseRef.current = {
217+
x: touch.clientX - rect.left,
218+
y: touch.clientY - rect.top,
219+
isActive: true,
220+
};
221+
};
222+
223+
const handleTouchEnd = () => {
224+
mouseRef.current = { ...mouseRef.current, isActive: false };
225+
};
226+
227+
const handleResize = () => setupCanvas();
228+
229+
window.addEventListener('resize', handleResize);
230+
canvas.addEventListener('mousemove', handleMouseMove);
231+
canvas.addEventListener('mouseleave', handleMouseLeave);
232+
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
233+
canvas.addEventListener('touchend', handleTouchEnd);
234+
235+
return () => {
236+
cancelAnimationFrame(animationRef.current);
237+
window.removeEventListener('resize', handleResize);
238+
canvas.removeEventListener('mousemove', handleMouseMove);
239+
canvas.removeEventListener('mouseleave', handleMouseLeave);
240+
canvas.removeEventListener('touchmove', handleTouchMove);
241+
canvas.removeEventListener('touchend', handleTouchEnd);
242+
};
243+
}, [generateParticlesFromText]);
244+
245+
return <canvas ref={canvasRef} className={className} style={canvasStyle} />;
246+
}
247+
248+
export default memo(ParticleCanvas);

0 commit comments

Comments
 (0)