Having fun with particles in HTML canvas and JavaScript

Author: Mathias Bøe

Welcome to the world of physics simulation with Javascript

This whole idea came about from a random YouTube video popping up in my feed. A guy selling a course on HTML/Javascript and canvas elements, he was modifying image pixels with canvas in a particle system.

Full disclosure, I did not watch the whole video so I can not say if the video is good or bad, I did however watch some of it to get the gist of what was happening and how I could replicate this in React.

You can view the full code below the result of this experiment.

Final result

The code

The component

To render the image as he does in the video poses a couple of issues when working with react. One is the fact that loading the image on first load in canvas didn't work as expected. The <img /> tag has a onload function you should be able to use. I, however, couldn't make it work, so I used JavaScript for this.

You do not want to initiate the canvas before the image is loaded. This is especially true when the image you use is hosted on a remote server, such as sanity.

PhysicsImage.tsx

_25
/**
_25
* PhysicsImageProps
_25
* @param src: url to image
_25
* @param particleSystemOptions: (Optional)
_25
*/
_25
interface PhysicsImageProps {
_25
src: string
_25
particleSystemOptions?: IParticleSystemOptions | null
_25
}
_25
_25
export default function PhysicsImage({
_25
src,
_25
particleSystemOptions,
_25
}: PhysicsImageProps): JSX.Element {
_25
let effect: null | Effect = null
_25
const imageRef = useRef<HTMLImageElement>(null)
_25
const canvasRef = useRef<HTMLCanvasElement>(null)
_25
_25
return (
_25
<div className="flex flex-col items-center justify-center px-0">
_25
<canvas ref={canvasRef} />
_25
<img ref={imageRef} />
_25
</div>
_25
)
_25
}

Loading the image data

To solve this issue I ended up dropping the <img /> tag, this was also done to not have the image in the html of the component, as that requires you to hide the element with css. After all, the image is only being used to calculate pixel colors and values, not actually rendering the image.

PhysicsImage.tsx

_23
/**
_23
* PhysicsImageProps
_23
* @param src: url to image
_23
* @param particleSystemOptions: (Optional)
_23
*/
_23
interface PhysicsImageProps {
_23
src: string
_23
particleSystemOptions?: IParticleSystemOptions | null
_23
}
_23
_23
export default function PhysicsImage({
_23
src,
_23
particleSystemOptions,
_23
}: PhysicsImageProps): JSX.Element {
_23
let effect: null | Effect = null
_23
const canvasRef = useRef<HTMLCanvasElement>(null)
_23
_23
return (
_23
<div className="flex flex-col items-center justify-center px-0">
_23
<canvas ref={canvasRef} />
_23
</div>
_23
)
_23
}

To get the correct image data the image was built with a useEffect and the Image api. Building it with the API has the added benefit of removing the need for another useRef hook.

PhysicsImage.tsx

_34
/**
_34
* PhysicsImageProps
_34
* @param src: url to image
_34
* @param particleSystemOptions: (Optional)
_34
*/
_34
interface PhysicsImageProps {
_34
src: string
_34
particleSystemOptions?: IParticleSystemOptions | null
_34
}
_34
_34
export default function PhysicsImage({
_34
src,
_34
particleSystemOptions,
_34
}: PhysicsImageProps): JSX.Element {
_34
const [loaded, setLoaded] = useState(false)
_34
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
_34
const canvasRef = useRef<HTMLCanvasElement>(null)
_34
_34
useEffect(() => {
_34
let image = new Image()
_34
image.src = src
_34
image.crossOrigin = 'anonymous'
_34
image.onload = function () {
_34
setImageSrc(image)
_34
setLoaded(true)
_34
}
_34
}, [])
_34
_34
return (
_34
<div className="flex flex-col items-center justify-center px-0">
_34
<canvas ref={canvasRef} />
_34
</div>
_34
)
_34
}

PhysicsImage.tsx

_34
/**
_34
* PhysicsImageProps
_34
* @param src: url to image
_34
* @param particleSystemOptions: (Optional)
_34
*/
_34
interface PhysicsImageProps {
_34
src: string
_34
particleSystemOptions?: IParticleSystemOptions | null
_34
}
_34
_34
export default function PhysicsImage({
_34
src,
_34
particleSystemOptions,
_34
}: PhysicsImageProps): JSX.Element {
_34
const [loaded, setLoaded] = useState(false)
_34
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
_34
const canvasRef = useRef<HTMLCanvasElement>(null)
_34
_34
useEffect(() => {
_34
let image = new Image()
_34
image.src = src
_34
image.crossOrigin = 'anonymous'
_34
image.onload = function () {
_34
setImageSrc(image)
_34
setLoaded(true)
_34
}
_34
}, [])
_34
_34
return (
_34
<div className="flex flex-col items-center justify-center px-0">
_34
<canvas ref={canvasRef} />
_34
</div>
_34
)
_34
}

From the code you can see that the loaded state is set to true as soon as the image is loaded, there is also an extra step of setting the image in a state variable. This could probably be omitted, but it works for now. When working locally with image from sanity, the image.crossOrigin has to be set to 'anonymous', or you will get a insecure action error from the canvas API.

PhysicsImage.tsx

_63
/**
_63
* PhysicsImageProps
_63
* @param src: url to image
_63
* @param particleSystemOptions: (Optional)
_63
*/
_63
interface PhysicsImageProps {
_63
src: string
_63
particleSystemOptions?: IParticleSystemOptions | null
_63
}
_63
_63
export default function PhysicsImage({
_63
src,
_63
particleSystemOptions,
_63
}: PhysicsImageProps): JSX.Element {
_63
let effect: null | Effect = null
_63
const [loaded, setLoaded] = useState(false)
_63
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
_63
const canvasRef = useRef<HTMLCanvasElement>(null)
_63
_63
_63
useEffect(() => {
_63
if (loaded) {
_63
const ctx = canvasRef.current.getContext('2d')
_63
const particleOptions: IParticleSystemOptions = particleSystemOptions
_63
? particleSystemOptions
_63
: {
_63
randomFriction: { min: 0.8, max: 0.9 },
_63
mouseRadius: 3000,
_63
ease: 0.01,
_63
size: 3,
_63
gap: 3,
_63
}
_63
canvasRef.current.width = window.innerWidth
_63
canvasRef.current.height = window.innerHeight
_63
effect = new Effect(canvasRef.current, imageSrc, particleOptions)
_63
_63
effect.init(ctx)
_63
animate()
_63
}
_63
}, [loaded])
_63
_63
function animate() {
_63
effect.update()
_63
effect.render(canvasRef.current.getContext('2d'))
_63
requestAnimationFrame(animate)
_63
}
_63
_63
useEffect(() => {
_63
let image = new Image()
_63
image.src = src
_63
image.crossOrigin = 'anonymous'
_63
image.onload = function () {
_63
setImageSrc(image)
_63
setLoaded(true)
_63
}
_63
}, [])
_63
_63
return (
_63
<div className="flex flex-col items-center justify-center px-0">
_63
<canvas ref={canvasRef} />
_63
</div>
_63
)
_63
}

To initialize the class we need to add another function to run the setup logic. I opted to do this in a useEffect this is not the correct way, as it would be more performant to do it with a function running from the previous useEffect. This way you could probably also omit the state variables for image and loading all together.

This code though runs the code as the image loads and initializes the particle system.

PhysicsImage.tsx

_63
/**
_63
* PhysicsImageProps
_63
* @param src: url to image
_63
* @param particleSystemOptions: (Optional)
_63
*/
_63
interface PhysicsImageProps {
_63
src: string
_63
particleSystemOptions?: IParticleSystemOptions | null
_63
}
_63
_63
export default function PhysicsImage({
_63
src,
_63
particleSystemOptions,
_63
}: PhysicsImageProps): JSX.Element {
_63
let effect: null | Effect = null
_63
const [loaded, setLoaded] = useState(false)
_63
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
_63
const canvasRef = useRef<HTMLCanvasElement>(null)
_63
_63
_63
useEffect(() => {
_63
if (loaded) {
_63
const ctx = canvasRef.current.getContext('2d')
_63
const particleOptions: IParticleSystemOptions = particleSystemOptions
_63
? particleSystemOptions
_63
: {
_63
randomFriction: { min: 0.8, max: 0.9 },
_63
mouseRadius: 3000,
_63
ease: 0.01,
_63
size: 3,
_63
gap: 3,
_63
}
_63
canvasRef.current.width = window.innerWidth
_63
canvasRef.current.height = window.innerHeight
_63
effect = new Effect(canvasRef.current, imageSrc, particleOptions)
_63
_63
effect.init(ctx)
_63
animate()
_63
}
_63
}, [loaded])
_63
_63
function animate() {
_63
effect.update()
_63
effect.render(canvasRef.current.getContext('2d'))
_63
requestAnimationFrame(animate)
_63
}
_63
_63
useEffect(() => {
_63
let image = new Image()
_63
image.src = src
_63
image.crossOrigin = 'anonymous'
_63
image.onload = function () {
_63
setImageSrc(image)
_63
setLoaded(true)
_63
}
_63
}, [])
_63
_63
return (
_63
<div className="flex flex-col items-center justify-center px-0">
_63
<canvas ref={canvasRef} />
_63
</div>
_63
)
_63
}

At the end of initialization it runs the animate() function that starts the rendering process in the canvas. The component itself is fairly simple, only consisting of initializations and returning a simple div with a canvas element inside.

The particle system

PhysicsImage.tsx
Particle.ts

_76
import { Effect } from './Effect'
_76
_76
/**
_76
* ParticleOptions
_76
* @param friction - The friction of the particle (default 0.9), number between 0 and 1
_76
* @param ease - The ease of the particle (default 0.1)
_76
* @param size - The size of the particle (default 1)
_76
* @param gap - Gap between particles (default 3)
_76
* @param mouseRadius - The radius of the mouse (default 6000)
_76
* @param randomFriction - Random friction between min and max (default null)
_76
*/
_76
export interface IParticleSystemOptions {
_76
friction?: number
_76
ease?: number
_76
size?: number
_76
gap?: number
_76
mouseRadius?: number
_76
randomFriction?: { min: number; max: number }
_76
}
_76
_76
export class Particle {
_76
effect: Effect
_76
x: number
_76
y: number
_76
size: number
_76
color: string
_76
dx: number
_76
dy: number
_76
vx: number
_76
vy: number
_76
force: number
_76
angle: number
_76
distance: number
_76
friction: number
_76
ease: number
_76
originX: number
_76
originY: number
_76
constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76
this.effect = effect
_76
this.x = this.originX = x
_76
this.y = this.originY = y
_76
this.color = color
_76
this.dx = 0
_76
this.dy = 0
_76
this.vx = 0
_76
this.vy = 0
_76
this.distance = 0
_76
this.angle = 0
_76
this.force = 0
_76
this.friction = particleOptions?.friction
_76
? particleOptions.friction
_76
: particleOptions?.randomFriction
_76
? Math.random() *
_76
(particleOptions.randomFriction.max -
_76
particleOptions.randomFriction.min) +
_76
particleOptions.randomFriction.min
_76
: 0.9
_76
_76
this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76
this.size = particleOptions?.size ? particleOptions.size : 1
_76
}
_76
_76
update() {
_76
this.dx = this.effect.mouse.x - this.x
_76
this.dy = this.effect.mouse.y - this.y
_76
this.distance = this.dx * this.dx + this.dy * this.dy
_76
this.force = -this.effect.mouse.radius / this.distance
_76
if (this.distance < this.effect.mouse.radius) {
_76
this.angle = Math.atan2(this.dy, this.dx)
_76
this.vx += this.force * Math.cos(this.angle)
_76
this.vy += this.force * Math.sin(this.angle)
_76
}
_76
this.x += (this.vx *= this.friction) + (this.originX - this.x) * this.ease
_76
this.y += (this.vy *= this.friction) + (this.originY - this.y) * this.ease
_76
}
_76
}

There are many ways of creating particle systems, this one is largely based on the video, although the math behind it is not new. Visualizing particles have been studied and researched thoroughly by the visualization community and elsewhere.

This particle system is a pretty standard one and doesn't really have any real world use (as i see it) apart from being cool to play with.

Particle class

PhysicsImage.tsx
Particle.ts

_76
import { Effect } from './Effect'
_76
_76
/**
_76
* ParticleOptions
_76
* @param friction - The friction of the particle (default 0.9), number between 0 and 1
_76
* @param ease - The ease of the particle (default 0.1)
_76
* @param size - The size of the particle (default 1)
_76
* @param gap - Gap between particles (default 3)
_76
* @param mouseRadius - The radius of the mouse (default 6000)
_76
* @param randomFriction - Random friction between min and max (default null)
_76
*/
_76
export interface IParticleSystemOptions {
_76
friction?: number
_76
ease?: number
_76
size?: number
_76
gap?: number
_76
mouseRadius?: number
_76
randomFriction?: { min: number; max: number }
_76
}
_76
_76
export class Particle {
_76
effect: Effect
_76
x: number
_76
y: number
_76
size: number
_76
color: string
_76
dx: number
_76
dy: number
_76
vx: number
_76
vy: number
_76
force: number
_76
angle: number
_76
distance: number
_76
friction: number
_76
ease: number
_76
originX: number
_76
originY: number
_76
constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76
this.effect = effect
_76
this.x = this.originX = x
_76
this.y = this.originY = y
_76
this.color = color
_76
this.dx = 0
_76
this.dy = 0
_76
this.vx = 0
_76
this.vy = 0
_76
this.distance = 0
_76
this.angle = 0
_76
this.force = 0
_76
this.friction = particleOptions?.friction
_76
? particleOptions.friction
_76
: particleOptions?.randomFriction
_76
? Math.random() *
_76
(particleOptions.randomFriction.max -
_76
particleOptions.randomFriction.min) +
_76
particleOptions.randomFriction.min
_76
: 0.9
_76
_76
this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76
this.size = particleOptions?.size ? particleOptions.size : 1
_76
}
_76
_76
update() {
_76
this.dx = this.effect.mouse.x - this.x
_76
this.dy = this.effect.mouse.y - this.y
_76
this.distance = this.dx * this.dx + this.dy * this.dy
_76
this.force = -this.effect.mouse.radius / this.distance
_76
if (this.distance < this.effect.mouse.radius) {
_76
this.angle = Math.atan2(this.dy, this.dx)
_76
this.vx += this.force * Math.cos(this.angle)
_76
this.vy += this.force * Math.sin(this.angle)
_76
}
_76
this.x += (this.vx *= this.friction) + (this.originX - this.x) * this.ease
_76
this.y += (this.vy *= this.friction) + (this.originY - this.y) * this.ease
_76
}
_76
}

The main purpose of this class for me was to split the code into readable sections, using classes could however be omitted and would probably increase performance slightly, although not enough for me to sacrifice readability at the current stage of this implementation.

The Particle class initiates the particle objects with the correct values and exposes an update function that will be called whenever the particle would move.

PhysicsImage.tsx
Particle.ts

_76
import { Effect } from './Effect'
_76
_76
/**
_76
* ParticleOptions
_76
* @param friction - The friction of the particle (default 0.9), number between 0 and 1
_76
* @param ease - The ease of the particle (default 0.1)
_76
* @param size - The size of the particle (default 1)
_76
* @param gap - Gap between particles (default 3)
_76
* @param mouseRadius - The radius of the mouse (default 6000)
_76
* @param randomFriction - Random friction between min and max (default null)
_76
*/
_76
export interface IParticleSystemOptions {
_76
friction?: number
_76
ease?: number
_76
size?: number
_76
gap?: number
_76
mouseRadius?: number
_76
randomFriction?: { min: number; max: number }
_76
}
_76
_76
export class Particle {
_76
effect: Effect
_76
x: number
_76
y: number
_76
size: number
_76
color: string
_76
dx: number
_76
dy: number
_76
vx: number
_76
vy: number
_76
force: number
_76
angle: number
_76
distance: number
_76
friction: number
_76
ease: number
_76
originX: number
_76
originY: number
_76
constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76
this.effect = effect
_76
this.x = this.originX = x
_76
this.y = this.originY = y
_76
this.color = color
_76
this.dx = 0
_76
this.dy = 0
_76
this.vx = 0
_76
this.vy = 0
_76
this.distance = 0
_76
this.angle = 0
_76
this.force = 0
_76
this.friction = particleOptions?.friction
_76
? particleOptions.friction
_76
: particleOptions?.randomFriction
_76
? Math.random() *
_76
(particleOptions.randomFriction.max -
_76
particleOptions.randomFriction.min) +
_76
particleOptions.randomFriction.min
_76
: 0.9
_76
_76
this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76
this.size = particleOptions?.size ? particleOptions.size : 1
_76
}
_76
_76
update() {
_76
this.dx = this.effect.mouse.x - this.x
_76
this.dy = this.effect.mouse.y - this.y
_76
this.distance = this.dx * this.dx + this.dy * this.dy
_76
this.force = -this.effect.mouse.radius / this.distance
_76
if (this.distance < this.effect.mouse.radius) {
_76
this.angle = Math.atan2(this.dy, this.dx)
_76
this.vx += this.force * Math.cos(this.angle)
_76
this.vy += this.force * Math.sin(this.angle)
_76
}
_76
this.x += (this.vx *= this.friction) + (this.originX - this.x) * this.ease
_76
this.y += (this.vy *= this.friction) + (this.originY - this.y) * this.ease
_76
}
_76
}

It also checks if the users cursor is close to itself, and if it is within the mouse radius it applies a force based on the cursor speed.

PhysicsImage.tsx
Particle.ts

_76
import { Effect } from './Effect'
_76
_76
/**
_76
* ParticleOptions
_76
* @param friction - The friction of the particle (default 0.9), number between 0 and 1
_76
* @param ease - The ease of the particle (default 0.1)
_76
* @param size - The size of the particle (default 1)
_76
* @param gap - Gap between particles (default 3)
_76
* @param mouseRadius - The radius of the mouse (default 6000)
_76
* @param randomFriction - Random friction between min and max (default null)
_76
*/
_76
export interface IParticleSystemOptions {
_76
friction?: number
_76
ease?: number
_76
size?: number
_76
gap?: number
_76
mouseRadius?: number
_76
randomFriction?: { min: number; max: number }
_76
}
_76
_76
export class Particle {
_76
effect: Effect
_76
x: number
_76
y: number
_76
size: number
_76
color: string
_76
dx: number
_76
dy: number
_76
vx: number
_76
vy: number
_76
force: number
_76
angle: number
_76
distance: number
_76
friction: number
_76
ease: number
_76
originX: number
_76
originY: number
_76
constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76
this.effect = effect
_76
this.x = this.originX = x
_76
this.y = this.originY = y
_76
this.color = color
_76
this.dx = 0
_76
this.dy = 0
_76
this.vx = 0
_76
this.vy = 0
_76
this.distance = 0
_76
this.angle = 0
_76
this.force = 0
_76
this.friction = particleOptions?.friction
_76
? particleOptions.friction
_76
: particleOptions?.randomFriction
_76
? Math.random() *
_76
(particleOptions.randomFriction.max -
_76
particleOptions.randomFriction.min) +
_76
particleOptions.randomFriction.min
_76
: 0.9
_76
_76
this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76
this.size = particleOptions?.size ? particleOptions.size : 1
_76
}
_76
_76
update() {
_76
this.dx = this.effect.mouse.x - this.x
_76
this.dy = this.effect.mouse.y - this.y
_76
this.distance = this.dx * this.dx + this.dy * this.dy
_76
this.force = -this.effect.mouse.radius / this.distance
_76
if (this.distance < this.effect.mouse.radius) {
_76
this.angle = Math.atan2(this.dy, this.dx)
_76
this.vx += this.force * Math.cos(this.angle)
_76
this.vy += this.force * Math.sin(this.angle)
_76
}
_76
this.x += (this.vx *= this.friction) + (this.originX - this.x) * this.ease
_76
this.y += (this.vy *= this.friction) + (this.originY - this.y) * this.ease
_76
}
_76
}

It is worth mentioning that this class shares an interface with the Effect class. This should probably be extracted to separate files and be better implemented. The interface gives you an overview over props you can send into the math function above to affect the physics.

Effect class

PhysicsImage.tsx
Particle.ts
Effect.ts

_137
import { IParticleSystemOptions, Particle } from './Particle'
_137
_137
export class Effect {
_137
canvas: HTMLCanvasElement
_137
image: HTMLImageElement
_137
particleSystemOptions?: IParticleSystemOptions | null
_137
width: number
_137
height: number
_137
centerX: number
_137
centerY: number
_137
x: number
_137
y: number
_137
particles: Particle[]
_137
gap: number
_137
mouse: {
_137
x: number
_137
y: number
_137
radius: number
_137
tmpRadius: number
_137
}
_137
box: DOMRect
_137
canvasOffset: { x: number; y: number }
_137
_137
constructor(
_137
canvas: HTMLCanvasElement,
_137
image: HTMLImageElement,
_137
particleOptions?: IParticleSystemOptions | null
_137
) {
_137
this.canvas = canvas
_137
this.box = this.canvas.getBoundingClientRect()
_137
this.canvasOffset = { x: this.box.left, y: this.box.top }
_137
this.width = canvas.width
_137
this.height = canvas.height
_137
this.image = image
_137
this.centerX = this.width / 2
_137
this.centerY = this.height / 2
_137
this.x = this.centerX - this.image.width / 2
_137
this.y = this.centerY - this.image.height / 2
_137
this.particles = []
_137
this.gap = particleOptions?.gap ? particleOptions.gap : 3
_137
this.mouse = {
_137
x: 0,
_137
y: 0,
_137
radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137
tmpRadius: 0,
_137
}
_137
particleOptions && (this.particleSystemOptions = particleOptions)
_137
_137
window.addEventListener('scroll', (_) => {
_137
this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137
})
_137
_137
window.addEventListener('mousemove', (event) => {
_137
this.mouse.x = event.clientX - this.canvasOffset.x
_137
this.mouse.y = event.clientY - this.canvasOffset.y
_137
})
_137
_137
window.addEventListener('mousedown', (event) => {
_137
this.mouse.tmpRadius = this.mouse.radius
_137
this.mouse.radius = 0
_137
})
_137
_137
window.addEventListener('mouseup', (event) => {
_137
this.mouse.radius = this.mouse.tmpRadius
_137
this.mouse.tmpRadius = 0
_137
})
_137
_137
window.addEventListener(
_137
'touchstart',
_137
(event) => {
_137
this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137
this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchmove',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = event.targetTouches[0].clientX
_137
this.mouse.y = event.targetTouches[0].clientY
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchend',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = 0
_137
this.mouse.y = 0
_137
},
_137
false
_137
)
_137
}
_137
_137
public init(context) {
_137
context.drawImage(this.image, this.x, this.y)
_137
let pixels = context.getImageData(0, 0, this.width, this.height).data
_137
let index
_137
for (let y = 0; y < this.height; y += this.gap) {
_137
for (let x = 0; x < this.width; x += this.gap) {
_137
index = (y * this.width + x) * 4
_137
const red = pixels[index]
_137
const green = pixels[index + 1]
_137
const blue = pixels[index + 2]
_137
const color = 'rgb(' + red + ',' + green + ',' + blue + ')'
_137
_137
const alpha = pixels[index + 3]
_137
if (alpha > 0) {
_137
this.particleSystemOptions
_137
? this.particles.push(
_137
new Particle(this, x, y, color, this.particleSystemOptions)
_137
)
_137
: this.particles.push(new Particle(this, x, y, color))
_137
}
_137
}
_137
}
_137
context.clearRect(0, 0, this.width, this.height)
_137
}
_137
_137
public update() {
_137
for (const element of this.particles) {
_137
element.update()
_137
}
_137
}
_137
_137
public render(context) {
_137
context.clearRect(0, 0, this.width, this.height)
_137
for (const element of this.particles) {
_137
let p = element
_137
context.fillStyle = p.color
_137
context.fillRect(p.x, p.y, p.size, p.size)
_137
}
_137
}
_137
}

This class is gross, working with classes is not necessarily the most fun thing to do in JavaScript, it is literally 50 lines of code just to initialize the the class. This is of course due to me not using interfaces properly in these files as it is more a proof of concept rather than "this is how you should do it". It is however how you could do it. The effect also carries the eventListeners to handle mouse interaction.

Cursor position

PhysicsImage.tsx
Particle.ts
Effect.ts

_137
import { IParticleSystemOptions, Particle } from './Particle'
_137
_137
export class Effect {
_137
canvas: HTMLCanvasElement
_137
image: HTMLImageElement
_137
particleSystemOptions?: IParticleSystemOptions | null
_137
width: number
_137
height: number
_137
centerX: number
_137
centerY: number
_137
x: number
_137
y: number
_137
particles: Particle[]
_137
gap: number
_137
mouse: {
_137
x: number
_137
y: number
_137
radius: number
_137
tmpRadius: number
_137
}
_137
box: DOMRect
_137
canvasOffset: { x: number; y: number }
_137
_137
constructor(
_137
canvas: HTMLCanvasElement,
_137
image: HTMLImageElement,
_137
particleOptions?: IParticleSystemOptions | null
_137
) {
_137
this.canvas = canvas
_137
this.box = this.canvas.getBoundingClientRect()
_137
this.canvasOffset = { x: this.box.left, y: this.box.top }
_137
this.width = canvas.width
_137
this.height = canvas.height
_137
this.image = image
_137
this.centerX = this.width / 2
_137
this.centerY = this.height / 2
_137
this.x = this.centerX - this.image.width / 2
_137
this.y = this.centerY - this.image.height / 2
_137
this.particles = []
_137
this.gap = particleOptions?.gap ? particleOptions.gap : 3
_137
this.mouse = {
_137
x: 0,
_137
y: 0,
_137
radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137
tmpRadius: 0,
_137
}
_137
particleOptions && (this.particleSystemOptions = particleOptions)
_137
_137
window.addEventListener('scroll', (_) => {
_137
this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137
})
_137
_137
window.addEventListener('mousemove', (event) => {
_137
this.mouse.x = event.clientX - this.canvasOffset.x
_137
this.mouse.y = event.clientY - this.canvasOffset.y
_137
})
_137
_137
window.addEventListener('mousedown', (event) => {
_137
this.mouse.tmpRadius = this.mouse.radius
_137
this.mouse.radius = 0
_137
})
_137
_137
window.addEventListener('mouseup', (event) => {
_137
this.mouse.radius = this.mouse.tmpRadius
_137
this.mouse.tmpRadius = 0
_137
})
_137
_137
window.addEventListener(
_137
'touchstart',
_137
(event) => {
_137
this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137
this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchmove',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = event.targetTouches[0].clientX
_137
this.mouse.y = event.targetTouches[0].clientY
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchend',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = 0
_137
this.mouse.y = 0
_137
},
_137
false
_137
)
_137
}
_137
_137
public init(context) {
_137
context.drawImage(this.image, this.x, this.y)
_137
let pixels = context.getImageData(0, 0, this.width, this.height).data
_137
let index
_137
for (let y = 0; y < this.height; y += this.gap) {
_137
for (let x = 0; x < this.width; x += this.gap) {
_137
index = (y * this.width + x) * 4
_137
const red = pixels[index]
_137
const green = pixels[index + 1]
_137
const blue = pixels[index + 2]
_137
const color = 'rgb(' + red + ',' + green + ',' + blue + ')'
_137
_137
const alpha = pixels[index + 3]
_137
if (alpha > 0) {
_137
this.particleSystemOptions
_137
? this.particles.push(
_137
new Particle(this, x, y, color, this.particleSystemOptions)
_137
)
_137
: this.particles.push(new Particle(this, x, y, color))
_137
}
_137
}
_137
}
_137
context.clearRect(0, 0, this.width, this.height)
_137
}
_137
_137
public update() {
_137
for (const element of this.particles) {
_137
element.update()
_137
}
_137
}
_137
_137
public render(context) {
_137
context.clearRect(0, 0, this.width, this.height)
_137
for (const element of this.particles) {
_137
let p = element
_137
context.fillStyle = p.color
_137
context.fillRect(p.x, p.y, p.size, p.size)
_137
}
_137
}
_137
}

The hardest part about making this canvas working out the mouse center. In a canvas that spans the entire width and height of the screen, think figma or google docs, the center of the cursor is usually where the client thinks it is. However, the cursor is not where it thinks it is in this document, as there are padding and margins in the document and other DOM elements that move the canvas around on the page.

It was therefore necessary to find the offset from the top of the website. This part of the browser is not that well documented, but it wasn't too hard to find similar issues on stack overflow. It was however hard to find the correct solution, and I am still not convinced I have the proper solution for this, but it does work as intended, at least for desktop.

PhysicsImage.tsx
Particle.ts
Effect.ts

_137
import { IParticleSystemOptions, Particle } from './Particle'
_137
_137
export class Effect {
_137
canvas: HTMLCanvasElement
_137
image: HTMLImageElement
_137
particleSystemOptions?: IParticleSystemOptions | null
_137
width: number
_137
height: number
_137
centerX: number
_137
centerY: number
_137
x: number
_137
y: number
_137
particles: Particle[]
_137
gap: number
_137
mouse: {
_137
x: number
_137
y: number
_137
radius: number
_137
tmpRadius: number
_137
}
_137
box: DOMRect
_137
canvasOffset: { x: number; y: number }
_137
_137
constructor(
_137
canvas: HTMLCanvasElement,
_137
image: HTMLImageElement,
_137
particleOptions?: IParticleSystemOptions | null
_137
) {
_137
this.canvas = canvas
_137
this.box = this.canvas.getBoundingClientRect()
_137
this.canvasOffset = { x: this.box.left, y: this.box.top }
_137
this.width = canvas.width
_137
this.height = canvas.height
_137
this.image = image
_137
this.centerX = this.width / 2
_137
this.centerY = this.height / 2
_137
this.x = this.centerX - this.image.width / 2
_137
this.y = this.centerY - this.image.height / 2
_137
this.particles = []
_137
this.gap = particleOptions?.gap ? particleOptions.gap : 3
_137
this.mouse = {
_137
x: 0,
_137
y: 0,
_137
radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137
tmpRadius: 0,
_137
}
_137
particleOptions && (this.particleSystemOptions = particleOptions)
_137
_137
window.addEventListener('scroll', (_) => {
_137
this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137
})
_137
_137
window.addEventListener('mousemove', (event) => {
_137
this.mouse.x = event.clientX - this.canvasOffset.x
_137
this.mouse.y = event.clientY - this.canvasOffset.y
_137
})
_137
_137
window.addEventListener('mousedown', (event) => {
_137
this.mouse.tmpRadius = this.mouse.radius
_137
this.mouse.radius = 0
_137
})
_137
_137
window.addEventListener('mouseup', (event) => {
_137
this.mouse.radius = this.mouse.tmpRadius
_137
this.mouse.tmpRadius = 0
_137
})
_137
_137
window.addEventListener(
_137
'touchstart',
_137
(event) => {
_137
this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137
this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchmove',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = event.targetTouches[0].clientX
_137
this.mouse.y = event.targetTouches[0].clientY
_137
},
_137
false
_137
)
_137
_137
window.addEventListener(
_137
'touchend',
_137
(event) => {
_137
event.preventDefault()
_137
this.mouse.x = 0
_137
this.mouse.y = 0
_137
},
_137
false
_137
)
_137
}
_137
_137
public init(context) {
_137
context.drawImage(this.image, this.x, this.y)
_137
let pixels = context.getImageData(0, 0, this.width, this.height).data
_137
let index
_137
for (let y = 0; y < this.height; y += this.gap) {
_137
for (let x = 0; x < this.width; x += this.gap) {
_137
index = (y * this.width + x) * 4
_137
const red = pixels[index]
_137
const green = pixels[index + 1]
_137
const blue = pixels[index + 2]
_137
const color = 'rgb(' + red + ',' + green + ',' + blue + ')'
_137
_137
const alpha = pixels[index + 3]
_137
if (alpha > 0) {
_137
this.particleSystemOptions
_137
? this.particles.push(
_137
new Particle(this, x, y, color, this.particleSystemOptions)
_137
)
_137
: this.particles.push(new Particle(this, x, y, color))
_137
}
_137
}
_137
}
_137
context.clearRect(0, 0, this.width, this.height)
_137
}
_137
_137
public update() {
_137
for (const element of this.particles) {
_137
element.update()
_137
}
_137
}
_137
_137
public render(context) {
_137
context.clearRect(0, 0, this.width, this.height)
_137
for (const element of this.particles) {
_137
let p = element
_137
context.fillStyle = p.color
_137
context.fillRect(p.x, p.y, p.size, p.size)
_137
}
_137
}
_137
}

Note: The eventListeners for mobile and touch is not correctly implemented in this example, but I do think the same offset should work for all screens and devices.

The component

To render the image as he does in the video poses a couple of issues when working with react. One is the fact that loading the image on first load in canvas didn't work as expected. The <img /> tag has a onload function you should be able to use. I, however, couldn't make it work, so I used JavaScript for this.

You do not want to initiate the canvas before the image is loaded. This is especially true when the image you use is hosted on a remote server, such as sanity.

Loading the image data

To solve this issue I ended up dropping the <img /> tag, this was also done to not have the image in the html of the component, as that requires you to hide the element with css. After all, the image is only being used to calculate pixel colors and values, not actually rendering the image.

To get the correct image data the image was built with a useEffect and the Image api. Building it with the API has the added benefit of removing the need for another useRef hook.

From the code you can see that the loaded state is set to true as soon as the image is loaded, there is also an extra step of setting the image in a state variable. This could probably be omitted, but it works for now. When working locally with image from sanity, the image.crossOrigin has to be set to 'anonymous', or you will get a insecure action error from the canvas API.

To initialize the class we need to add another function to run the setup logic. I opted to do this in a useEffect this is not the correct way, as it would be more performant to do it with a function running from the previous useEffect. This way you could probably also omit the state variables for image and loading all together.

This code though runs the code as the image loads and initializes the particle system.

At the end of initialization it runs the animate() function that starts the rendering process in the canvas. The component itself is fairly simple, only consisting of initializations and returning a simple div with a canvas element inside.

The particle system

There are many ways of creating particle systems, this one is largely based on the video, although the math behind it is not new. Visualizing particles have been studied and researched thoroughly by the visualization community and elsewhere.

This particle system is a pretty standard one and doesn't really have any real world use (as i see it) apart from being cool to play with.

Particle class

The main purpose of this class for me was to split the code into readable sections, using classes could however be omitted and would probably increase performance slightly, although not enough for me to sacrifice readability at the current stage of this implementation.

The Particle class initiates the particle objects with the correct values and exposes an update function that will be called whenever the particle would move.

It also checks if the users cursor is close to itself, and if it is within the mouse radius it applies a force based on the cursor speed.

It is worth mentioning that this class shares an interface with the Effect class. This should probably be extracted to separate files and be better implemented. The interface gives you an overview over props you can send into the math function above to affect the physics.

Effect class

This class is gross, working with classes is not necessarily the most fun thing to do in JavaScript, it is literally 50 lines of code just to initialize the the class. This is of course due to me not using interfaces properly in these files as it is more a proof of concept rather than "this is how you should do it". It is however how you could do it. The effect also carries the eventListeners to handle mouse interaction.

Cursor position

The hardest part about making this canvas working out the mouse center. In a canvas that spans the entire width and height of the screen, think figma or google docs, the center of the cursor is usually where the client thinks it is. However, the cursor is not where it thinks it is in this document, as there are padding and margins in the document and other DOM elements that move the canvas around on the page.

It was therefore necessary to find the offset from the top of the website. This part of the browser is not that well documented, but it wasn't too hard to find similar issues on stack overflow. It was however hard to find the correct solution, and I am still not convinced I have the proper solution for this, but it does work as intended, at least for desktop.

Note: The eventListeners for mobile and touch is not correctly implemented in this example, but I do think the same offset should work for all screens and devices.

PhysicsImage.tsx
ExpandClose

_25
/**
_25
* PhysicsImageProps
_25
* @param src: url to image
_25
* @param particleSystemOptions: (Optional)
_25
*/
_25
interface PhysicsImageProps {
_25
src: string
_25
particleSystemOptions?: IParticleSystemOptions | null
_25
}
_25
_25
export default function PhysicsImage({
_25
src,
_25
particleSystemOptions,
_25
}: PhysicsImageProps): JSX.Element {
_25
let effect: null | Effect = null
_25
const imageRef = useRef<HTMLImageElement>(null)
_25
const canvasRef = useRef<HTMLCanvasElement>(null)
_25
_25
return (
_25
<div className="flex flex-col items-center justify-center px-0">
_25
<canvas ref={canvasRef} />
_25
<img ref={imageRef} />
_25
</div>
_25
)
_25
}

Final code

PhysicsImage.tsx
Particle.ts
Effect.ts

_66
import { useEffect, useRef, useState } from 'react'
_66
import { Effect } from '../lib/particleSystem/Effect'
_66
import { IParticleSystemOptions } from '@/lib/particleSystem/Particle'
_66
_66
/**
_66
* PhysicsImageProps
_66
* @param src: url to image
_66
* @param particleSystemOptions: (Optional)
_66
*/
_66
interface PhysicsImageProps {
_66
src: string
_66
particleSystemOptions?: IParticleSystemOptions | null
_66
}
_66
_66
export default function PhysicsImage({
_66
src,
_66
particleSystemOptions,
_66
}: PhysicsImageProps): JSX.Element {
_66
let effect: null | Effect = null
_66
const [loaded, setLoaded] = useState(false)
_66
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
_66
const canvasRef = useRef<HTMLCanvasElement>(null)
_66
_66
useEffect(() => {
_66
if (loaded) {
_66
const ctx = canvasRef.current.getContext('2d')
_66
const particleOptions: IParticleSystemOptions = particleSystemOptions
_66
? particleSystemOptions
_66
: {
_66
randomFriction: { min: 0.8, max: 0.9 },
_66
mouseRadius: 3000,
_66
ease: 0.01,
_66
size: 3,
_66
gap: 3,
_66
}
_66
canvasRef.current.width = window.innerWidth
_66
canvasRef.current.height = window.innerHeight
_66
effect = new Effect(canvasRef.current, imageSrc, particleOptions)
_66
_66
effect.init(ctx)
_66
animate()
_66
}
_66
}, [loaded])
_66
_66
function animate() {
_66
effect.update()
_66
effect.render(canvasRef.current.getContext('2d'))
_66
requestAnimationFrame(animate)
_66
}
_66
_66
useEffect(() => {
_66
let image = new Image()
_66
image.src = src
_66
image.crossOrigin = 'anonymous'
_66
image.onload = function () {
_66
setImageSrc(image)
_66
setLoaded(true)
_66
}
_66
}, [])
_66
_66
return (
_66
<div className="flex flex-col items-center justify-center px-0">
_66
<canvas ref={canvasRef} />
_66
</div>
_66
)
_66
}

Future

This has been a fun project, but I think implementing this into pure webGL will provide a massive performance boost. It would also be really nice to have sliders below the image to control the different variables.

If you have questions or ideas feel free to contact me via email