Skip to content

Instantly share code, notes, and snippets.

@keiver
Last active January 6, 2025 21:24
Show Gist options
  • Select an option

  • Save keiver/de5d88c9b996cff17104525367d54b34 to your computer and use it in GitHub Desktop.

Select an option

Save keiver/de5d88c9b996cff17104525367d54b34 to your computer and use it in GitHub Desktop.
useAnimatedBackground.tsx hook.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment