A React hook that generates an animated canvas background featuring two visual effects: a dynamic particle systems for fading lines and a rippling waves for a depth effect.
import { useEffect } from "react"
export interface ColorConfig {
primary: string
secondary: string
background: string
overlay: string
}
export interface ParticleConfig {
count: number
minSpeed: number
maxSpeed: number
minSize: number
maxSize: number
trailAlpha: number
}
export interface RippleConfig {
spawnRate: number
speed: number
maxCount: number
alpha: number
}
export type Origin =
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
export interface AnimationConfig {
blurAmount: number
brightness: number
rotationSpeed: number
origin: Origin
}
export interface BackgroundConfig {
colors: ColorConfig
particles: ParticleConfig
ripples: RippleConfig
animation: AnimationConfig
}
export interface ParticleProps {
x: number
y: number
speed: number
size: number
angle: number
}
export interface IParticle {
x: number
y: number
speed: number
size: number
angle: number
distance: number
alpha: number
blueShift: number
update(rect: DOMRect): void
draw(ctx: CanvasRenderingContext2D): void
}
export interface IRipple {
x: number
y: number
radius: number
maxRadius: number
speed: number
alpha: number
update(): boolean
draw(ctx: CanvasRenderingContext2D, colors: ColorConfig): void
}
export type UseAnimatedBackground = (config?: Partial<BackgroundConfig>) => void
const defaultConfig: BackgroundConfig = {
colors: {
primary: "rgba(135, 206, 255, 1)",
secondary: "rgba(0, 150, 255, 0.4)",
background: "#000",
overlay: "rgba(0, 150, 255, 0.1)"
},
particles: {
count: 100,
minSpeed: 1,
maxSpeed: 3,
minSize: 1,
maxSize: 3,
trailAlpha: 0.1
},
ripples: {
spawnRate: 0.05,
speed: 2,
maxCount: 5,
alpha: 0.5
},
animation: {
blurAmount: 2,
brightness: 1.2,
rotationSpeed: 20,
origin: "center"
}
}
const getOriginCoords = (
origin: Origin,
rect: DOMRect
): { x: number; y: number } => {
const { width, height } = rect
switch (origin) {
case "center":
return { x: width / 2, y: height / 2 }
case "top-left":
return { x: 0, y: 0 }
case "top-right":
return { x: width, y: 0 }
case "bottom-left":
return { x: 0, y: height }
case "bottom-right":
return { x: width, y: height }
default:
return { x: width / 2, y: height / 2 }
}
}
class Particle {
x: number
y: number
speed: number
size: number
angle: number
distance: number
alpha: number
blueShift: number
private colors: ColorConfig
private origin: { x: number; y: number }
constructor(
{ x, y, speed, size, angle }: ParticleProps,
colors: ColorConfig,
origin: { x: number; y: number }
) {
this.x = x
this.y = y
this.speed = speed
this.size = size
this.angle = angle
this.distance = 0
this.alpha = 1
this.blueShift = Math.random() * 50
this.colors = colors
this.origin = origin
}
update(rect: DOMRect): void {
this.distance += this.speed
const newX = this.origin.x + Math.cos(this.angle) * this.distance
const newY = this.origin.y + Math.sin(this.angle) * this.distance
// check particle is outside viewport
if (
newX < -50 ||
newX > rect.width + 50 ||
newY < -50 ||
newY > rect.height + 50
) {
// reset particle to origin
this.distance = 0
this.alpha = 1
} else {
this.x = newX
this.y = newY
// alpha based on distance from origin, alpha is 0 at max distance and 1 at origin
// this is used to fade in particles as they move away from origin
const maxDistance = Math.max(
Math.sqrt(Math.pow(rect.width, 2) + Math.pow(rect.height, 2)) / 2,
this.distance
)
this.alpha = Math.max(0, 1 - this.distance / maxDistance)
}
}
draw(ctx: CanvasRenderingContext2D): void {
ctx.beginPath()
const gradient = ctx.createLinearGradient(
this.origin.x,
this.origin.y,
this.x,
this.y
)
const startColor = this.colors.primary.replace("1)", "0)")
const endColor = this.colors.primary.replace("1)", `${this.alpha})`)
gradient.addColorStop(0, startColor)
gradient.addColorStop(1, endColor)
ctx.strokeStyle = gradient
ctx.lineWidth = this.size
ctx.moveTo(this.origin.x, this.origin.y)
ctx.lineTo(this.x, this.y)
ctx.stroke()
}
}
class Ripple {
x: number
y: number
radius: number
maxRadius: number
speed: number
alpha: number
constructor(
rect: DOMRect,
config: RippleConfig,
origin: { x: number; y: number }
) {
this.x = origin.x
this.y = origin.y
this.radius = 0
const corners = [
{ x: 0, y: 0 },
{ x: rect.width, y: 0 },
{ x: 0, y: rect.height },
{ x: rect.width, y: rect.height }
]
// maxRadius is the distance from origin to the farthest corner of the viewport
this.maxRadius = corners.reduce((max, corner) => {
const distance = Math.sqrt(
Math.pow(corner.x - origin.x, 2) + Math.pow(corner.y - origin.y, 2)
)
return Math.max(max, distance)
}, 0)
this.speed = config.speed
this.alpha = config.alpha
}
update(): boolean {
this.radius += this.speed
this.alpha = 1 - this.radius / this.maxRadius
return this.radius <= this.maxRadius
}
draw(ctx: CanvasRenderingContext2D, colors: ColorConfig): void {
ctx.beginPath()
ctx.strokeStyle = colors.primary.replace("1)", `${this.alpha * 0.5})`)
ctx.lineWidth = 2
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.stroke()
}
}
const applyStyles = (element: HTMLElement, styles: Record<string, string>): void => {
Object.entries(styles).forEach(([key, value]) => {
element.style[key as any] = value
})
}
const useAnimatedBackground = (config: Partial<BackgroundConfig> = {}): void => {
useEffect(() => {
const mergedConfig: BackgroundConfig = {
...defaultConfig,
...config,
colors: { ...defaultConfig.colors, ...config.colors },
particles: { ...defaultConfig.particles, ...config.particles },
ripples: { ...defaultConfig.ripples, ...config.ripples },
animation: { ...defaultConfig.animation, ...config.animation }
}
const container = document.createElement("div")
const canvas = document.createElement("canvas")
const lightEffects = document.createElement("div")
const waterOverlay = document.createElement("div")
const energyField = document.createElement("div")
applyStyles(container, {
position: "fixed",
top: "0",
left: "0",
width: "100vw",
height: "100vh",
zIndex: "-1",
overflow: "hidden",
background: mergedConfig.colors.background
})
applyStyles(canvas, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%"
})
applyStyles(lightEffects, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
mixBlendMode: "screen",
filter: `blur(${mergedConfig.animation.blurAmount}px) brightness(${mergedConfig.animation.brightness})`
})
applyStyles(waterOverlay, {
position: "absolute",
top: "-50%",
left: "-50%",
width: "200%",
height: "200%",
background: `repeating-radial-gradient(circle at center, transparent 0, ${mergedConfig.colors.overlay} 20px, transparent 40px)`,
animation: `rotate ${mergedConfig.animation.rotationSpeed}s linear infinite`,
mixBlendMode: "overlay",
opacity: "0.7"
})
applyStyles(energyField, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
background: `radial-gradient(circle at center, ${mergedConfig.colors.secondary} 0%, transparent 70%)`,
filter: "blur(5px)",
mixBlendMode: "screen"
})
const style = document.createElement("style")
style.textContent = `
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
document.head.appendChild(style)
container.appendChild(canvas)
container.appendChild(lightEffects)
container.appendChild(waterOverlay)
container.appendChild(energyField)
document.body.appendChild(container)
const ctx = canvas.getContext("2d")
if (!ctx) {
return console.error("Failed to get canvas context")
}
const particles: Particle[] = []
const ripples: Ripple[] = []
const updateCanvasSize = (): void => {
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
// set canvas size in device pixels
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
// scale context to match device pixel ratio
ctx.scale(dpr, dpr)
// origin coordinates based on viewport rect
const origin = getOriginCoords(mergedConfig.animation.origin, rect)
// reset particles with new origin
particles.length = 0
for (let i = 0; i < mergedConfig.particles.count; i++) {
const angle = (i / mergedConfig.particles.count) * Math.PI * 2
const speed =
mergedConfig.particles.minSpeed +
Math.random() *
(mergedConfig.particles.maxSpeed - mergedConfig.particles.minSpeed)
const size =
mergedConfig.particles.minSize +
Math.random() *
(mergedConfig.particles.maxSize - mergedConfig.particles.minSize)
particles.push(
new Particle(
{
x: origin.x,
y: origin.y,
speed,
size,
angle
},
mergedConfig.colors,
origin
)
)
}
}
const animate = (): void => {
const rect = canvas.getBoundingClientRect()
ctx.fillStyle = `rgba(0, 0, 0, ${mergedConfig.particles.trailAlpha})`
ctx.fillRect(0, 0, rect.width, rect.height)
particles.forEach(particle => {
particle.update(rect)
particle.draw(ctx)
})
const origin = getOriginCoords(mergedConfig.animation.origin, rect)
// update and draw ripples
for (let i = ripples.length - 1; i >= 0; i--) {
if (!ripples[i].update()) {
ripples.splice(i, 1)
} else {
ripples[i].draw(ctx, mergedConfig.colors)
}
}
// create new ripples
if (
Math.random() < mergedConfig.ripples.spawnRate &&
ripples.length < mergedConfig.ripples.maxCount
) {
ripples.push(new Ripple(rect, mergedConfig.ripples, origin))
}
requestAnimationFrame(animate)
}
updateCanvasSize()
window.addEventListener("resize", updateCanvasSize)
const animationFrameId = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener("resize", updateCanvasSize)
document.body.removeChild(container)
document.head.removeChild(style)
}
}, [config])
}
export default useAnimatedBackground