Implementing Canvas Animations on a Website
Canvas animations are direct rendering through 2D or WebGL context in the browser. Unlike SVG and DOM animations, Canvas redraws the entire frame on each tick—this provides maximum performance for thousands of objects, but requires manual rendering management. Use cases: particles, physics simulations, procedural effects, game mechanics, real-time data visualizations.
Canvas Animation Architecture
Standard cycle: initialization → requestAnimationFrame → frame clear → object rendering → state update → next frame.
// lib/canvas-engine.ts
export interface AnimationContext {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
width: number
height: number
dpr: number // device pixel ratio
dt: number // delta time in seconds
}
export type RenderFn = (context: AnimationContext) => void
export class CanvasEngine {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private dpr: number
private rafId: number | null = null
private lastTime: number = 0
private renderFn: RenderFn
constructor(canvas: HTMLCanvasElement, renderFn: RenderFn) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
this.dpr = window.devicePixelRatio || 1
this.renderFn = renderFn
this.resize()
}
resize() {
const { canvas, dpr } = this
const rect = canvas.getBoundingClientRect()
// High resolution for retina
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
this.ctx.scale(dpr, dpr)
}
start() {
this.lastTime = performance.now()
this.tick(this.lastTime)
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}
private tick = (timestamp: number) => {
const dt = Math.min((timestamp - this.lastTime) / 1000, 0.1) // cap at 100ms
this.lastTime = timestamp
const rect = this.canvas.getBoundingClientRect()
this.renderFn({
canvas: this.canvas,
ctx: this.ctx,
width: rect.width,
height: rect.height,
dpr: this.dpr,
dt,
})
this.rafId = requestAnimationFrame(this.tick)
}
}
React Hook for Canvas
// hooks/useCanvas.ts
import { useEffect, useRef } from 'react'
import { CanvasEngine, RenderFn } from '../lib/canvas-engine'
export function useCanvas(renderFn: RenderFn) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<CanvasEngine | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const engine = new CanvasEngine(canvas, renderFn)
engineRef.current = engine
engine.start()
const handleResize = () => engine.resize()
window.addEventListener('resize', handleResize)
return () => {
engine.stop()
window.removeEventListener('resize', handleResize)
}
}, [renderFn])
return canvasRef
}
Example: Particle System with Physics
// lib/particle-system.ts
interface Particle {
x: number
y: number
vx: number
vy: number
radius: number
color: string
life: number // 0–1
maxLife: number // seconds
}
export class ParticleSystem {
private particles: Particle[] = []
private readonly maxParticles: number
constructor(maxParticles = 500) {
this.maxParticles = maxParticles
}
emit(x: number, y: number, count = 5) {
for (let i = 0; i < count; i++) {
if (this.particles.length >= this.maxParticles) break
const angle = Math.random() * Math.PI * 2
const speed = 50 + Math.random() * 150
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 100, // initial upward impulse
radius: 2 + Math.random() * 4,
color: `hsl(${200 + Math.random() * 60}, 80%, 60%)`,
life: 1,
maxLife: 0.8 + Math.random() * 0.8,
})
}
}
update(dt: number) {
const gravity = 300 // px/s²
this.particles = this.particles.filter(p => {
p.x += p.vx * dt
p.y += p.vy * dt
p.vy += gravity * dt
p.vx *= 0.99 // damping
p.life -= dt / p.maxLife
return p.life > 0
})
}
draw(ctx: CanvasRenderingContext2D) {
for (const p of this.particles) {
ctx.save()
ctx.globalAlpha = p.life * p.life // quadratic fade
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius * p.life, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
}
// components/ParticleCanvas.tsx
import { useRef, useCallback } from 'react'
import { useCanvas } from '../hooks/useCanvas'
import { ParticleSystem } from '../lib/particle-system'
export function ParticleCanvas() {
const systemRef = useRef(new ParticleSystem(800))
const render = useCallback(({ ctx, width, height, dt }: AnimationContext) => {
// Clear with semi-transparent trail (motion blur effect)
ctx.fillStyle = 'rgba(15, 15, 25, 0.15)'
ctx.fillRect(0, 0, width, height)
systemRef.current.update(dt)
systemRef.current.draw(ctx)
// Automatic emission at center
if (Math.random() < 0.3) {
systemRef.current.emit(
width / 2 + (Math.random() - 0.5) * 100,
height / 2
)
}
}, [])
const canvasRef = useCanvas(render)
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current!.getBoundingClientRect()
systemRef.current.emit(e.clientX - rect.left, e.clientY - rect.top, 20)
}
return (
<canvas
ref={canvasRef}
className="w-full h-full bg-[#0f0f19] cursor-crosshair"
onClick={handleClick}
/>
)
}
WebGL via Three.js: The Next Level
For complex 3D scenes in website backgrounds:
npm install three @types/three
// components/ThreeBackground.tsx
'use client'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
export function ThreeBackground() {
const mountRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const mount = mountRef.current!
const width = mount.clientWidth
const height = mount.clientHeight
// Scene
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
camera.position.z = 50
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
mount.appendChild(renderer.domElement)
// Particle geometry
const count = 3000
const positions = new Float32Array(count * 3)
for (let i = 0; i < count * 3; i++) {
positions[i] = (Math.random() - 0.5) * 200
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const material = new THREE.PointsMaterial({
size: 0.3,
color: 0x3b82f6,
transparent: true,
opacity: 0.7,
})
const points = new THREE.Points(geometry, material)
scene.add(points)
// Animation loop
let rafId: number
const animate = () => {
rafId = requestAnimationFrame(animate)
points.rotation.x += 0.0003
points.rotation.y += 0.0005
renderer.render(scene, camera)
}
animate()
const handleResize = () => {
const w = mount.clientWidth
const h = mount.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
window.addEventListener('resize', handleResize)
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', handleResize)
renderer.dispose()
mount.removeChild(renderer.domElement)
}
}, [])
return <div ref={mountRef} className="absolute inset-0 -z-10" />
}
Off-screen Canvas (Web Worker)
For very heavy computations—move rendering to a Worker via OffscreenCanvas:
// main thread
const canvas = document.getElementById('my-canvas') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker(new URL('./canvas-worker.ts', import.meta.url))
worker.postMessage({ canvas: offscreen, width: canvas.width, height: canvas.height }, [offscreen])
// canvas-worker.ts
self.onmessage = (e) => {
const { canvas, width, height } = e.data
const ctx = canvas.getContext('2d')!
// All rendering happens here, in the Worker
}
Typical Timelines
Simple Canvas animation (particles, waves)—1–2 working days. Full particle system with physics, interactivity, and optimization—3–5 days. Three.js scene with shaders, post-processing, and adaptive scaling—from 1 week.







