Smooth Wavy Canvas

An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.

1
"use client"
2
3
import { useEffect, useRef, useCallback } from "react"
4
5
interface SmoothWavyCanvasProps {
6
backgroundColor?: string
7
primaryColor?: string
8
secondaryColor?: string
9
accentColor?: string
10
lineOpacity?: number
11
animationSpeed?: number
12
}
13
14
const SmoothWavyCanvas = ({
15
backgroundColor = "#F8F6F0",
16
primaryColor = "45, 45, 45",
17
secondaryColor = "80, 80, 80",
18
accentColor = "120, 120, 120",
19
lineOpacity = 1,
20
animationSpeed = 0.004,
21
}: SmoothWavyCanvasProps) => {
22
const canvasRef = useRef<HTMLCanvasElement>(null)
23
const requestIdRef = useRef<number | null>(null)
24
const timeRef = useRef<number>(0)
25
const mouseRef = useRef({ x: 0, y: 0, isDown: false })
26
const energyFields = useRef<Array<{ x: number; y: number; time: number; intensity: number }>>([])
27
28
const getMouseInfluence = (x: number, y: number): number => {
29
const dx = x - mouseRef.current.x
30
const dy = y - mouseRef.current.y
31
const distance = Math.sqrt(dx * dx + dy * dy)
32
const maxDistance = 200
33
return Math.max(0, 1 - distance / maxDistance)
34
}
35
36
const getEnergyFieldInfluence = (
37
x: number,
38
y: number,
39
currentTime: number,
40
): { intensity: number; direction: number } => {
41
let totalIntensity = 0
42
let totalDirectionX = 0
43
let totalDirectionY = 0
44
45
energyFields.current.forEach((field) => {
46
const age = currentTime - field.time
47
const maxAge = 4000
48
49
if (age < maxAge) {
50
const dx = x - field.x
51
const dy = y - field.y
52
const distance = Math.sqrt(dx * dx + dy * dy)
53
const fieldRadius = (age / maxAge) * 300
54
const fieldWidth = 100
55
56
if (Math.abs(distance - fieldRadius) < fieldWidth) {
57
const fieldStrength = (1 - age / maxAge) * field.intensity
58
const proximityToField = 1 - Math.abs(distance - fieldRadius) / fieldWidth
59
const influence = fieldStrength * proximityToField * 0.6 // Reduced intensity
60
61
totalIntensity += influence
62
if (distance > 0) {
63
totalDirectionX += (dx / distance) * influence
64
totalDirectionY += (dy / distance) * influence
65
}
66
}
67
}
68
})
69
70
const direction = Math.atan2(totalDirectionY, totalDirectionX)
71
return { intensity: Math.min(totalIntensity, 1), direction } // Capped at 1 instead of 2
72
}
73
74
const resizeCanvas = useCallback(() => {
75
const canvas = canvasRef.current
76
if (!canvas) return
77
canvas.width = window.innerWidth
78
canvas.height = window.innerHeight
79
}, [])
80
81
const handleMouseMove = useCallback((e: MouseEvent) => {
82
const canvas = canvasRef.current
83
if (!canvas) return
84
85
const rect = canvas.getBoundingClientRect()
86
mouseRef.current.x = e.clientX - rect.left
87
mouseRef.current.y = e.clientY - rect.top
88
}, [])
89
90
const handleMouseDown = useCallback((e: MouseEvent) => {
91
mouseRef.current.isDown = true
92
// Removed click effects - no more energy fields created
93
}, [])
94
95
const handleMouseUp = useCallback(() => {
96
mouseRef.current.isDown = false
97
}, [])
98
99
const animate = useCallback(() => {
100
const canvas = canvasRef.current
101
if (!canvas) return
102
103
const ctx = canvas.getContext("2d")
104
if (!ctx) return
105
106
const currentTime = Date.now()
107
timeRef.current += animationSpeed
108
109
const width = canvas.width
110
const height = canvas.height
111
112
// Clear with clean background
113
ctx.fillStyle = backgroundColor
114
ctx.fillRect(0, 0, width, height)
115
116
// Primary horizontal flowing lines
117
const numPrimaryLines = 35
118
119
for (let i = 0; i < numPrimaryLines; i++) {
120
const yPos = (i / numPrimaryLines) * height
121
const mouseInfl = getMouseInfluence(width / 2, yPos)
122
const { intensity: fieldIntensity, direction: fieldDirection } = getEnergyFieldInfluence(
123
width / 2,
124
yPos,
125
currentTime,
126
)
127
128
const amplitude = 45 + 25 * Math.sin(timeRef.current * 0.25 + i * 0.15) + mouseInfl * 25
129
const frequency = 0.006 + 0.002 * Math.sin(timeRef.current * 0.12 + i * 0.08) + mouseInfl * 0.001
130
const speed = timeRef.current * (0.6 + 0.3 * Math.sin(i * 0.12)) + mouseInfl * timeRef.current * 0.3
131
const thickness = 0.6 + 0.4 * Math.sin(timeRef.current + i * 0.25) + mouseInfl * 0.8
132
const opacity =
133
(0.12 + 0.08 * Math.abs(Math.sin(timeRef.current * 0.3 + i * 0.18)) + mouseInfl * 0.15) *
134
lineOpacity
135
136
ctx.beginPath()
137
ctx.lineWidth = thickness
138
ctx.strokeStyle = `rgba(${primaryColor}, ${opacity})`
139
140
for (let x = 0; x < width; x += 2) {
141
const localMouseInfl = getMouseInfluence(x, yPos)
142
143
const y =
144
yPos +
145
amplitude * Math.sin(x * frequency + speed) +
146
localMouseInfl * Math.sin(timeRef.current * 2 + x * 0.008) * 15
147
148
if (x === 0) {
149
ctx.moveTo(x, y)
150
} else {
151
ctx.lineTo(x, y)
152
}
153
}
154
155
ctx.stroke()
156
}
157
158
// Secondary vertical flowing lines
159
const numSecondaryLines = 25
160
161
for (let i = 0; i < numSecondaryLines; i++) {
162
const xPos = (i / numSecondaryLines) * width
163
const mouseInfl = getMouseInfluence(xPos, height / 2)
164
const { intensity: fieldIntensity, direction: fieldDirection } = getEnergyFieldInfluence(
165
xPos,
166
height / 2,
167
currentTime,
168
)
169
170
const amplitude = 40 + 20 * Math.sin(timeRef.current * 0.18 + i * 0.14) + mouseInfl * 20
171
const frequency = 0.007 + 0.003 * Math.cos(timeRef.current * 0.14 + i * 0.09) + mouseInfl * 0.002
172
const speed = timeRef.current * (0.5 + 0.25 * Math.cos(i * 0.16)) + mouseInfl * timeRef.current * 0.25
173
const thickness = 0.5 + 0.3 * Math.sin(timeRef.current + i * 0.35) + mouseInfl * 0.7
174
const opacity =
175
(0.1 + 0.06 * Math.abs(Math.sin(timeRef.current * 0.28 + i * 0.2)) + mouseInfl * 0.12) *
176
lineOpacity
177
178
ctx.beginPath()
179
ctx.lineWidth = thickness
180
ctx.strokeStyle = `rgba(${secondaryColor}, ${opacity})`
181
182
for (let y = 0; y < height; y += 2) {
183
const localMouseInfl = getMouseInfluence(xPos, y)
184
185
const x =
186
xPos +
187
amplitude * Math.sin(y * frequency + speed) +
188
localMouseInfl * Math.sin(timeRef.current * 2 + y * 0.008) * 12
189
190
if (y === 0) {
191
ctx.moveTo(x, y)
192
} else {
193
ctx.lineTo(x, y)
194
}
195
}
196
197
ctx.stroke()
198
}
199
200
// Accent diagonal flowing lines
201
const numAccentLines = 15
202
203
for (let i = 0; i < numAccentLines; i++) {
204
const offset = (i / numAccentLines) * width * 1.5 - width * 0.25
205
const amplitude = 30 + 15 * Math.cos(timeRef.current * 0.22 + i * 0.12)
206
const frequency = 0.01 + 0.004 * Math.sin(timeRef.current * 0.16 + i * 0.1)
207
const phase = timeRef.current * (0.4 + 0.2 * Math.sin(i * 0.13))
208
const thickness = 0.4 + 0.25 * Math.sin(timeRef.current + i * 0.28)
209
const opacity = (0.06 + 0.04 * Math.abs(Math.sin(timeRef.current * 0.24 + i * 0.15))) * lineOpacity
210
211
ctx.beginPath()
212
ctx.lineWidth = thickness
213
ctx.strokeStyle = `rgba(${accentColor}, ${opacity})`
214
215
const steps = 100
216
for (let j = 0; j <= steps; j++) {
217
const progress = j / steps
218
const baseX = offset + progress * width
219
const baseY = progress * height + amplitude * Math.sin(progress * 6 + phase)
220
221
const mouseInfl = getMouseInfluence(baseX, baseY)
222
223
const x =
224
baseX +
225
mouseInfl * Math.sin(timeRef.current * 1.5 + progress * 6) * 8
226
const y =
227
baseY +
228
mouseInfl * Math.cos(timeRef.current * 1.5 + progress * 6) * 8
229
230
if (j === 0) {
231
ctx.moveTo(x, y)
232
} else {
233
ctx.lineTo(x, y)
234
}
235
}
236
237
ctx.stroke()
238
}
239
240
// No energy field effects - removed completely
241
242
requestIdRef.current = requestAnimationFrame(animate)
243
}, [backgroundColor, primaryColor, secondaryColor, accentColor, lineOpacity, animationSpeed])
244
245
useEffect(() => {
246
const canvas = canvasRef.current
247
if (!canvas) return
248
249
resizeCanvas()
250
251
const handleResize = () => resizeCanvas()
252
window.addEventListener("resize", handleResize)
253
canvas.addEventListener("mousemove", handleMouseMove)
254
canvas.addEventListener("mousedown", handleMouseDown)
255
canvas.addEventListener("mouseup", handleMouseUp)
256
257
animate()
258
259
return () => {
260
window.removeEventListener("resize", handleResize)
261
canvas.removeEventListener("mousemove", handleMouseMove)
262
canvas.removeEventListener("mousedown", handleMouseDown)
263
canvas.removeEventListener("mouseup", handleMouseUp)
264
265
if (requestIdRef.current) {
266
cancelAnimationFrame(requestIdRef.current)
267
requestIdRef.current = null
268
}
269
270
timeRef.current = 0
271
energyFields.current = []
272
}
273
}, [animate, resizeCanvas, handleMouseMove, handleMouseDown, handleMouseUp])
274
275
return (
276
<div className="absolute inset-0 w-full h-full overflow-hidden" style={{ backgroundColor }}>
277
<canvas ref={canvasRef} className="block w-full h-full" />
278
</div>
279
)
280
}
281
282
export default SmoothWavyCanvas

Props

PropTypeDefaultDescription
backgroundColorstring'#F8F6F0'Background color of the canvas.
primaryColorstring'45, 45, 45'RGB value for the primary wavy line.
secondaryColorstring'80, 80, 80'RGB value for the secondary wavy line.
accentColorstring'120, 120, 120'RGB value for the accent wavy line.
lineOpacitynumber1Opacity level for all wavy lines.
animationSpeednumber0.004Speed of the wave animation.