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.
_25 * @param src: url to image
_25 * @param particleSystemOptions: (Optional)
_25interface PhysicsImageProps {
_25 particleSystemOptions?: IParticleSystemOptions | null
_25export default function PhysicsImage({
_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 <div className="flex flex-col items-center justify-center px-0">
_25 <canvas ref={canvasRef} />
_25 <img ref={imageRef} />
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.
_23 * @param src: url to image
_23 * @param particleSystemOptions: (Optional)
_23interface PhysicsImageProps {
_23 particleSystemOptions?: IParticleSystemOptions | null
_23export default function PhysicsImage({
_23 particleSystemOptions,
_23}: PhysicsImageProps): JSX.Element {
_23 let effect: null | Effect = null
_23 const canvasRef = useRef<HTMLCanvasElement>(null)
_23 <div className="flex flex-col items-center justify-center px-0">
_23 <canvas ref={canvasRef} />
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.
_34 * @param src: url to image
_34 * @param particleSystemOptions: (Optional)
_34interface PhysicsImageProps {
_34 particleSystemOptions?: IParticleSystemOptions | null
_34export default function PhysicsImage({
_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 let image = new Image()
_34 image.crossOrigin = 'anonymous'
_34 image.onload = function () {
_34 <div className="flex flex-col items-center justify-center px-0">
_34 <canvas ref={canvasRef} />
_34 * @param src: url to image
_34 * @param particleSystemOptions: (Optional)
_34interface PhysicsImageProps {
_34 particleSystemOptions?: IParticleSystemOptions | null
_34export default function PhysicsImage({
_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 let image = new Image()
_34 image.crossOrigin = 'anonymous'
_34 image.onload = function () {
_34 <div className="flex flex-col items-center justify-center px-0">
_34 <canvas ref={canvasRef} />
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.
_63 * @param src: url to image
_63 * @param particleSystemOptions: (Optional)
_63interface PhysicsImageProps {
_63 particleSystemOptions?: IParticleSystemOptions | null
_63export default function PhysicsImage({
_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 const ctx = canvasRef.current.getContext('2d')
_63 const particleOptions: IParticleSystemOptions = particleSystemOptions
_63 ? particleSystemOptions
_63 randomFriction: { min: 0.8, max: 0.9 },
_63 canvasRef.current.width = window.innerWidth
_63 canvasRef.current.height = window.innerHeight
_63 effect = new Effect(canvasRef.current, imageSrc, particleOptions)
_63 effect.render(canvasRef.current.getContext('2d'))
_63 requestAnimationFrame(animate)
_63 let image = new Image()
_63 image.crossOrigin = 'anonymous'
_63 image.onload = function () {
_63 <div className="flex flex-col items-center justify-center px-0">
_63 <canvas ref={canvasRef} />
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.
_63 * @param src: url to image
_63 * @param particleSystemOptions: (Optional)
_63interface PhysicsImageProps {
_63 particleSystemOptions?: IParticleSystemOptions | null
_63export default function PhysicsImage({
_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 const ctx = canvasRef.current.getContext('2d')
_63 const particleOptions: IParticleSystemOptions = particleSystemOptions
_63 ? particleSystemOptions
_63 randomFriction: { min: 0.8, max: 0.9 },
_63 canvasRef.current.width = window.innerWidth
_63 canvasRef.current.height = window.innerHeight
_63 effect = new Effect(canvasRef.current, imageSrc, particleOptions)
_63 effect.render(canvasRef.current.getContext('2d'))
_63 requestAnimationFrame(animate)
_63 let image = new Image()
_63 image.crossOrigin = 'anonymous'
_63 image.onload = function () {
_63 <div className="flex flex-col items-center justify-center px-0">
_63 <canvas ref={canvasRef} />
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
_76import { Effect } from './Effect'
_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)
_76export interface IParticleSystemOptions {
_76 randomFriction?: { min: number; max: number }
_76export class Particle {
_76 constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76 this.x = this.originX = x
_76 this.y = this.originY = y
_76 this.friction = particleOptions?.friction
_76 ? particleOptions.friction
_76 : particleOptions?.randomFriction
_76 (particleOptions.randomFriction.max -
_76 particleOptions.randomFriction.min) +
_76 particleOptions.randomFriction.min
_76 this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76 this.size = particleOptions?.size ? particleOptions.size : 1
_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 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
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
_76import { Effect } from './Effect'
_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)
_76export interface IParticleSystemOptions {
_76 randomFriction?: { min: number; max: number }
_76export class Particle {
_76 constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76 this.x = this.originX = x
_76 this.y = this.originY = y
_76 this.friction = particleOptions?.friction
_76 ? particleOptions.friction
_76 : particleOptions?.randomFriction
_76 (particleOptions.randomFriction.max -
_76 particleOptions.randomFriction.min) +
_76 particleOptions.randomFriction.min
_76 this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76 this.size = particleOptions?.size ? particleOptions.size : 1
_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 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
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.
_76import { Effect } from './Effect'
_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)
_76export interface IParticleSystemOptions {
_76 randomFriction?: { min: number; max: number }
_76export class Particle {
_76 constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76 this.x = this.originX = x
_76 this.y = this.originY = y
_76 this.friction = particleOptions?.friction
_76 ? particleOptions.friction
_76 : particleOptions?.randomFriction
_76 (particleOptions.randomFriction.max -
_76 particleOptions.randomFriction.min) +
_76 particleOptions.randomFriction.min
_76 this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76 this.size = particleOptions?.size ? particleOptions.size : 1
_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 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
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.
_76import { Effect } from './Effect'
_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)
_76export interface IParticleSystemOptions {
_76 randomFriction?: { min: number; max: number }
_76export class Particle {
_76 constructor(effect, x, y, color, particleOptions?: IParticleSystemOptions) {
_76 this.x = this.originX = x
_76 this.y = this.originY = y
_76 this.friction = particleOptions?.friction
_76 ? particleOptions.friction
_76 : particleOptions?.randomFriction
_76 (particleOptions.randomFriction.max -
_76 particleOptions.randomFriction.min) +
_76 particleOptions.randomFriction.min
_76 this.ease = particleOptions?.ease ? particleOptions.ease : 0.1
_76 this.size = particleOptions?.size ? particleOptions.size : 1
_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 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
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
_137import { IParticleSystemOptions, Particle } from './Particle'
_137export class Effect {
_137 canvas: HTMLCanvasElement
_137 image: HTMLImageElement
_137 particleSystemOptions?: IParticleSystemOptions | null
_137 particles: Particle[]
_137 canvasOffset: { x: number; y: number }
_137 canvas: HTMLCanvasElement,
_137 image: HTMLImageElement,
_137 particleOptions?: IParticleSystemOptions | null
_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.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.gap = particleOptions?.gap ? particleOptions.gap : 3
_137 radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137 particleOptions && (this.particleSystemOptions = particleOptions)
_137 window.addEventListener('scroll', (_) => {
_137 this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137 window.addEventListener('mousemove', (event) => {
_137 this.mouse.x = event.clientX - this.canvasOffset.x
_137 this.mouse.y = event.clientY - this.canvasOffset.y
_137 window.addEventListener('mousedown', (event) => {
_137 this.mouse.tmpRadius = this.mouse.radius
_137 this.mouse.radius = 0
_137 window.addEventListener('mouseup', (event) => {
_137 this.mouse.radius = this.mouse.tmpRadius
_137 this.mouse.tmpRadius = 0
_137 window.addEventListener(
_137 this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137 this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137 window.addEventListener(
_137 event.preventDefault()
_137 this.mouse.x = event.targetTouches[0].clientX
_137 this.mouse.y = event.targetTouches[0].clientY
_137 window.addEventListener(
_137 event.preventDefault()
_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 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 const alpha = pixels[index + 3]
_137 this.particleSystemOptions
_137 ? this.particles.push(
_137 new Particle(this, x, y, color, this.particleSystemOptions)
_137 : this.particles.push(new Particle(this, x, y, color))
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 public render(context) {
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 context.fillStyle = p.color
_137 context.fillRect(p.x, p.y, p.size, p.size)
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
_137import { IParticleSystemOptions, Particle } from './Particle'
_137export class Effect {
_137 canvas: HTMLCanvasElement
_137 image: HTMLImageElement
_137 particleSystemOptions?: IParticleSystemOptions | null
_137 particles: Particle[]
_137 canvasOffset: { x: number; y: number }
_137 canvas: HTMLCanvasElement,
_137 image: HTMLImageElement,
_137 particleOptions?: IParticleSystemOptions | null
_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.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.gap = particleOptions?.gap ? particleOptions.gap : 3
_137 radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137 particleOptions && (this.particleSystemOptions = particleOptions)
_137 window.addEventListener('scroll', (_) => {
_137 this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137 window.addEventListener('mousemove', (event) => {
_137 this.mouse.x = event.clientX - this.canvasOffset.x
_137 this.mouse.y = event.clientY - this.canvasOffset.y
_137 window.addEventListener('mousedown', (event) => {
_137 this.mouse.tmpRadius = this.mouse.radius
_137 this.mouse.radius = 0
_137 window.addEventListener('mouseup', (event) => {
_137 this.mouse.radius = this.mouse.tmpRadius
_137 this.mouse.tmpRadius = 0
_137 window.addEventListener(
_137 this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137 this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137 window.addEventListener(
_137 event.preventDefault()
_137 this.mouse.x = event.targetTouches[0].clientX
_137 this.mouse.y = event.targetTouches[0].clientY
_137 window.addEventListener(
_137 event.preventDefault()
_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 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 const alpha = pixels[index + 3]
_137 this.particleSystemOptions
_137 ? this.particles.push(
_137 new Particle(this, x, y, color, this.particleSystemOptions)
_137 : this.particles.push(new Particle(this, x, y, color))
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 public render(context) {
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 context.fillStyle = p.color
_137 context.fillRect(p.x, p.y, p.size, p.size)
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.
_137import { IParticleSystemOptions, Particle } from './Particle'
_137export class Effect {
_137 canvas: HTMLCanvasElement
_137 image: HTMLImageElement
_137 particleSystemOptions?: IParticleSystemOptions | null
_137 particles: Particle[]
_137 canvasOffset: { x: number; y: number }
_137 canvas: HTMLCanvasElement,
_137 image: HTMLImageElement,
_137 particleOptions?: IParticleSystemOptions | null
_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.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.gap = particleOptions?.gap ? particleOptions.gap : 3
_137 radius: particleOptions?.mouseRadius ? particleOptions.mouseRadius : 100,
_137 particleOptions && (this.particleSystemOptions = particleOptions)
_137 window.addEventListener('scroll', (_) => {
_137 this.canvasOffset.y = this.canvas.getBoundingClientRect().top
_137 window.addEventListener('mousemove', (event) => {
_137 this.mouse.x = event.clientX - this.canvasOffset.x
_137 this.mouse.y = event.clientY - this.canvasOffset.y
_137 window.addEventListener('mousedown', (event) => {
_137 this.mouse.tmpRadius = this.mouse.radius
_137 this.mouse.radius = 0
_137 window.addEventListener('mouseup', (event) => {
_137 this.mouse.radius = this.mouse.tmpRadius
_137 this.mouse.tmpRadius = 0
_137 window.addEventListener(
_137 this.mouse.x = event.changedTouches[0].clientX - this.canvasOffset.x
_137 this.mouse.y = event.changedTouches[0].clientY - this.canvasOffset.y
_137 window.addEventListener(
_137 event.preventDefault()
_137 this.mouse.x = event.targetTouches[0].clientX
_137 this.mouse.y = event.targetTouches[0].clientY
_137 window.addEventListener(
_137 event.preventDefault()
_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 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 const alpha = pixels[index + 3]
_137 this.particleSystemOptions
_137 ? this.particles.push(
_137 new Particle(this, x, y, color, this.particleSystemOptions)
_137 : this.particles.push(new Particle(this, x, y, color))
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 public render(context) {
_137 context.clearRect(0, 0, this.width, this.height)
_137 for (const element of this.particles) {
_137 context.fillStyle = p.color
_137 context.fillRect(p.x, p.y, p.size, p.size)
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.