Skip to content

Commit 2323935

Browse files
committed
feat: mouse move trail
1 parent 91d7934 commit 2323935

4 files changed

Lines changed: 258 additions & 21 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React, { useEffect, useRef } from 'react'
2+
import clsx from 'clsx'
3+
4+
const MagicTrail = ({
5+
className,
6+
colors = ['#8b5cf6', '#ec4899', '#3b82f6', '#10b981'],
7+
particleCount = 50,
8+
trailLength = 35,
9+
decay = 0.03,
10+
smoothing = 0.65,
11+
containerRef,
12+
}) => {
13+
const canvasRef = useRef(null)
14+
const points = useRef([])
15+
const particles = useRef([])
16+
const mousePos = useRef({ x: 0, y: 0 })
17+
const targetPos = useRef({ x: 0, y: 0 })
18+
const animationFrameId = useRef(null)
19+
const colorIndex = useRef(0)
20+
const lastAddTime = useRef(0)
21+
const isPointerInBounds = useRef(true)
22+
23+
const createParticle = (x, y, color) => {
24+
const angle = Math.random() * Math.PI * 2
25+
const speed = Math.random() * 2 + 1
26+
return {
27+
x,
28+
y,
29+
vx: Math.cos(angle) * speed,
30+
vy: Math.sin(angle) * speed,
31+
life: 1,
32+
color,
33+
size: Math.random() * 3 + 1,
34+
}
35+
}
36+
37+
useEffect(() => {
38+
const canvas = canvasRef.current
39+
const container = containerRef?.current || canvas?.parentElement
40+
if (!canvas || !container) return
41+
42+
const ctx = canvas.getContext('2d', { alpha: true })
43+
if (!ctx) return
44+
45+
const updateCanvasSize = () => {
46+
const dpr = window.devicePixelRatio || 1
47+
const rect = container.getBoundingClientRect()
48+
canvas.width = rect.width * dpr
49+
canvas.height = rect.height * dpr
50+
ctx.scale(dpr, dpr)
51+
}
52+
53+
const handleMouseMove = (e) => {
54+
const rect = container.getBoundingClientRect()
55+
const x = e.clientX - rect.left
56+
const y = e.clientY - rect.top
57+
isPointerInBounds.current = x >= 0 && x <= rect.width && y >= 0 && y <= rect.height
58+
59+
if (isPointerInBounds.current) {
60+
targetPos.current = { x, y }
61+
}
62+
}
63+
64+
const handleMouseLeave = () => {
65+
isPointerInBounds.current = false
66+
}
67+
68+
const addPoint = () => {
69+
if (!isPointerInBounds.current) {
70+
return
71+
}
72+
73+
const now = performance.now()
74+
const timeDiff = now - lastAddTime.current
75+
76+
mousePos.current.x += (targetPos.current.x - mousePos.current.x) * smoothing
77+
mousePos.current.y += (targetPos.current.y - mousePos.current.y) * smoothing
78+
79+
const lastPoint = points.current[points.current.length - 1]
80+
const distance = lastPoint
81+
? Math.hypot(mousePos.current.x - lastPoint.x, mousePos.current.y - lastPoint.y)
82+
: Infinity
83+
84+
if (distance > 2 || timeDiff > 16) {
85+
const currentColor = colors[colorIndex.current]
86+
87+
points.current.push({
88+
x: mousePos.current.x,
89+
y: mousePos.current.y,
90+
age: 0,
91+
color: currentColor,
92+
})
93+
94+
for (let i = 0; i < 3; i++) {
95+
particles.current.push(createParticle(mousePos.current.x, mousePos.current.y, currentColor))
96+
}
97+
98+
if (distance > 10) {
99+
colorIndex.current = (colorIndex.current + 1) % colors.length
100+
}
101+
102+
lastAddTime.current = now
103+
104+
if (points.current.length > trailLength) {
105+
points.current.shift()
106+
}
107+
108+
if (particles.current.length > particleCount) {
109+
particles.current = particles.current.slice(-particleCount)
110+
}
111+
}
112+
}
113+
114+
const drawSparkle = (x, y, size, color) => {
115+
const opacity = Math.random() * 0.5 + 0.5
116+
ctx.strokeStyle = `${color}${Math.floor(opacity * 255)
117+
.toString(16)
118+
.padStart(2, '0')}`
119+
ctx.lineWidth = size * 0.5
120+
121+
for (let i = 0; i < 4; i++) {
122+
const angle = (Math.PI / 2) * i
123+
ctx.beginPath()
124+
ctx.moveTo(x - Math.cos(angle) * size, y - Math.sin(angle) * size)
125+
ctx.lineTo(x + Math.cos(angle) * size, y + Math.sin(angle) * size)
126+
ctx.stroke()
127+
}
128+
}
129+
130+
const animate = () => {
131+
ctx.clearRect(0, 0, canvas.width, canvas.height)
132+
ctx.globalCompositeOperation = 'lighter'
133+
134+
addPoint()
135+
136+
ctx.lineCap = 'round'
137+
ctx.lineJoin = 'round'
138+
ctx.shadowBlur = 15
139+
140+
for (let i = 1; i < points.current.length; i++) {
141+
const point = points.current[i]
142+
const prevPoint = points.current[i - 1]
143+
const opacity = Math.max(1 - point.age, 0)
144+
const size = Math.max(4 * (1 - point.age), 0)
145+
146+
ctx.shadowColor = point.color
147+
const gradient = ctx.createLinearGradient(prevPoint.x, prevPoint.y, point.x, point.y)
148+
149+
const prevOpacity = Math.max(1 - prevPoint.age, 0)
150+
gradient.addColorStop(
151+
0,
152+
`${prevPoint.color}${Math.floor(prevOpacity * 255)
153+
.toString(16)
154+
.padStart(2, '0')}`
155+
)
156+
gradient.addColorStop(
157+
1,
158+
`${point.color}${Math.floor(opacity * 255)
159+
.toString(16)
160+
.padStart(2, '0')}`
161+
)
162+
163+
ctx.beginPath()
164+
ctx.strokeStyle = gradient
165+
ctx.lineWidth = size
166+
ctx.moveTo(prevPoint.x, prevPoint.y)
167+
ctx.lineTo(point.x, point.y)
168+
ctx.stroke()
169+
}
170+
171+
ctx.shadowBlur = 0
172+
particles.current.forEach((particle) => {
173+
particle.x += particle.vx
174+
particle.y += particle.vy
175+
particle.vy += 0.05
176+
particle.life -= 0.02
177+
178+
// if (particle.life > 0) {
179+
// const opacity = particle.life;
180+
// drawSparkle(
181+
// particle.x,
182+
// particle.y,
183+
// particle.size * opacity,
184+
// particle.color,
185+
// );
186+
// }
187+
})
188+
189+
particles.current = particles.current.filter((p) => p.life > 0)
190+
191+
points.current.forEach((point) => {
192+
point.age += decay
193+
})
194+
points.current = points.current.filter((point) => point.age < 1)
195+
196+
animationFrameId.current = requestAnimationFrame(animate)
197+
}
198+
199+
updateCanvasSize()
200+
window.addEventListener('resize', updateCanvasSize)
201+
container.addEventListener('mousemove', handleMouseMove)
202+
container.addEventListener('mouseleave', handleMouseLeave)
203+
animate()
204+
205+
return () => {
206+
window.removeEventListener('resize', updateCanvasSize)
207+
container.removeEventListener('mousemove', handleMouseMove)
208+
container.removeEventListener('mouseleave', handleMouseLeave)
209+
if (animationFrameId.current) {
210+
cancelAnimationFrame(animationFrameId.current)
211+
}
212+
}
213+
}, [colors, trailLength, decay, smoothing, particleCount, containerRef])
214+
215+
return (
216+
<div
217+
className={clsx('pointer-events-none absolute inset-0', className)}
218+
style={{ width: '100%', height: '100%', zIndex: 100 }}
219+
>
220+
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
221+
</div>
222+
)
223+
}
224+
225+
export default MagicTrail

src/components/stateless/PointerMove/index.module.less

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.star {
22
width: 20px;
33
position: absolute;
4-
z-index: 999;
4+
z-index: 99;
55
aspect-ratio: 1;
66
background: #f8ca00;
77
clip-path: polygon(

src/pages/demo/index.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,18 @@ const ProDemo = () => {
105105
])
106106
const handleReorder = (newItems) => {
107107
setItems(newItems)
108-
// Do something with the new order
109108
}
110109
return (
111110
<FixTabPanel>
112111
<ScriptView showMultiplePackageOptions={true} codeLanguage="shell" commandMap={customCommandMap} />
113112
<section style={{ height: 240, overflow: 'hidden', margin: 20 }}>
114113
<AnimatedList>
115-
{Array.from({ length: 10 }, () => {
116-
id: Math.random()
117-
})
114+
{Array.from({ length: 10 }, () => ({
115+
id: Math.random(),
116+
}))
118117
.flat()
119118
.map((item, index) => (
120-
<div className="flex flex-col items-center justify-center gap-4">
119+
<div key={item?.id} className="flex flex-col items-center justify-center gap-4">
121120
<div className="flex items-center justify-center gap-4">
122121
<div className="h-16 w-100 rounded-full bg-gradient-to-br from-purple-500 to-blue-500" />
123122
</div>

src/pages/layout/index.jsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
1-
import React from 'react'
1+
import React, { useRef } from 'react'
22
import { Layout } from 'antd'
33
import { ProTabProvider } from '@hooks/proTabsContext'
44
import PointerMove from '@stateless/PointerMove'
5+
import MagicTrail from '@stateless/MagicTrail'
56
import ProHeader from './proHeader'
67
import ProSider from './proSider'
78
import ProContent from './proContent'
89
import ProSecNav from './proSecNav'
910
import styles from './index.module.less'
11+
import { constant } from 'lodash'
1012

11-
const ProLayout = () => (
12-
<Layout className={styles.layout}>
13-
<PointerMove />
14-
<ProTabProvider>
15-
<ProHeader />
16-
<Layout className={styles.layout}>
17-
<ProSider>
18-
<ProSecNav />
19-
</ProSider>
20-
<ProContent />
21-
</Layout>
22-
</ProTabProvider>
23-
</Layout>
24-
)
13+
const ProLayout = () => {
14+
const layoutRef = useRef(null)
15+
return (
16+
<Layout className={styles.layout} ref={layoutRef}>
17+
<PointerMove />
18+
<MagicTrail
19+
containerRef={layoutRef}
20+
colors={['#f59e0b', '#ec4899', '#8b5cf6']}
21+
trailLength={35}
22+
particleCount={75}
23+
decay={0.03}
24+
smoothing={0.65}
25+
/>
26+
<ProTabProvider>
27+
<ProHeader />
28+
<Layout className={styles.layout}>
29+
<ProSider>
30+
<ProSecNav />
31+
</ProSider>
32+
<ProContent />
33+
</Layout>
34+
</ProTabProvider>
35+
</Layout>
36+
)
37+
}
2538

2639
export default ProLayout

0 commit comments

Comments
 (0)