Adding Particle Effects to DOM Elements with Canvas

Avatar of Zach Saucier
Zach Saucier on (Updated on )

Let’s take a look at how to make web pages more visually capable by combining the freedom of <canvas> with HTML elements. Specifically, we will be creating a basic HTML-to-particle effect, but the same technique could be used for many kinds of effects.

Before we begin, feel free to grab the source code in the repo.

View Repo

Create the initial element

First, let’s create an HTML element to build on. I’m using a simple styled button, but it really could be any HTML element.

See the Pen DOM to Canvas #1 by Zach Saucier (@Zeaklous) on CodePen.

A modern browser like Chrome, Firefox, or Edge is required to view these demos.

But how can we get a canvas to “see” this element so that we can manipulate each pixel using canvas? In order to make that to happen, we will essentially need to take a snapshot of our HTML element — like a “print screen” but only for the particular element (the button) that we’re looking to manipulate in canvas.

Create a canvas version of our element

Even though there’s no native way for browsers to do this and allow us to manipulate it in JavaScript, there is a very handy library called html2canvas that can help us. All we have to do is load the library, then call html2canvas(element) and it will return a Promise along with a canvas version of our element! Crazy awesome.

See the Pen DOM to Canvas #2 by Zach Saucier (@Zeaklous) on CodePen.

Here we have an HTML version and a canvas version of our button next to each other. We can use the canvas version as our “screenshot” and as a source for information, such as the color of a pixel at a particular location.

Getting data from our canvas

To do that, let’s create a new function to get the pixel information at a particular location. We also don’t need to display the canvas that we’d get our color data from, because we want to show the original HTML element instead.

function getColorAtPoint(e) {
  // Get the coordinate of the click
  let x = e.offsetX;
  let y = e.offsetY;
  
  // Get the color data of the canvas version of our element at that location
  let rgbaColorArr = ctx.getImageData(x, y, 1, 1).data;

  // Do something with rgbaColorArr
}

See the Pen DOM to Canvas #3 by Zach Saucier (@Zeaklous) on CodePen.

Now we need to create a canvas particle using that information.

Create a canvas for displaying particles

We don’t really have a canvas to put the particles on yet because we want to reserve the canvas that we got from html2canvas for accessing color information only. So let’s create another:

var particleCanvas, particleCtx;
function createParticleCanvas() {
  // Create our canvas
  particleCanvas = document.createElement("canvas");
  particleCtx = particleCanvas.getContext("2d");
  
  // Size our canvas
  particleCanvas.width = window.innerWidth;
  particleCanvas.height = window.innerHeight;
  
  // Position out canvas
  particleCanvas.style.position = "absolute";
  particleCanvas.style.top = "0";
  particleCanvas.style.left = "0";
  
  // Make sure it's on top of other elements
  particleCanvas.style.zIndex = "1001";
  
  // Make sure other elements under it are clickable
  particleCanvas.style.pointerEvents = "none";
  
  // Add our canvas to the page
  document.body.appendChild(particleCanvas);
}

Get coordinate data

We also need to continue to get the color data from our local coordinate — not only the top and left of our button, but also the position in global coordinates (in relation to the entire web page) in order to create the particle in the right place on the canvas.

We can do so using the following:

btn.addEventListener("click", e => {
  // Get our color data like before
  let localX = e.offsetX;
  let localY = e.offsetY;
  let rgbaColorArr = ctx.getImageData(localX, localY, 1, 1).data;
  
  // Get the button's positioning in terms of the window
  let bcr = btn.getBoundingClientRect();
  let globalX = bcr.left + localX;
  let globalY = bcr.top + localY;
  
  // Create a particle using the color we obtained at the window location
  // that we calculated
  createParticleAtPoint(globalX, globalY, rgbaColorArr);
});

Create a particle prototype

And let’s also create a basic particle that has a draw function using variables:

/* An "exploding" particle effect that uses circles */
var ExplodingParticle = function() {
  // Set how long we want our particle to animate for
  this.animationDuration = 1000; // in ms

  // Set the speed for our particle
  this.speed = {
    x: -5 + Math.random() * 10,
    y: -5 + Math.random() * 10
  };
  
  // Size our particle
  this.radius = 5 + Math.random() * 5;
  
  // Set a max time to live for our particle
  this.life = 30 + Math.random() * 10;
  this.remainingLife = this.life;
  
  // This function will be called by our animation logic later on
  this.draw = ctx => {
    let p = this;

    if(this.remainingLife > 0
    && this.radius > 0) {
      // Draw a circle at the current location
      ctx.beginPath();
      ctx.arc(p.startX, p.startY, p.radius, 0, Math.PI * 2);
      ctx.fillStyle = "rgba(" + this.rgbArray[0] + ',' + this.rgbArray[1] + ',' + this.rgbArray[2] + ", 1)";
      ctx.fill();
      
      // Update the particle's location and life
      p.remainingLife--;
      p.radius -= 0.25;
      p.startX += p.speed.x;
      p.startY += p.speed.y;
    }
  }
}

Create a particle factory

We also need a function to create these particles based on some coordinates and color information, making sure that we add them to the array of particles that get created:

var particles = [];
function createParticleAtPoint(x, y, colorData) {
  let particle = new ExplodingParticle();
  particle.rgbArray = colorData;
  particle.startX = x;
  particle.startY = y;
  particle.startTime = Date.now();
  
  particles.push(particle);
}

Add animation logic

We also need a way to animate any particles that are created.

function update() {
  // Clear out the old particles
  if(typeof particleCtx !== "undefined") {
    particleCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
  }

  // Draw all of our particles in their new location
  for(let i = 0; i < particles.length; i++) {
    particles[i].draw(particleCtx);
    
    // Simple way to clean up if the last particle is done animating
    if(i === particles.length - 1) {
      let percent = (Date.now() - particles[i].startTime) / particles[i].animationDuration[i];
      
      if(percent > 1) {
        particles = [];
      }
    }
  }
  
  // Animate performantly
  window.requestAnimationFrame(update);
}

window.requestAnimationFrame(update);

Putting those pieces together, we can now create particles based on our HTML element when we click it!

See the Pen DOM to Canvas #4 by Zach Saucier (@Zeaklous) on CodePen.

A particle fires out of the button each time it is clicked.

Neat!

If we want to “explode” the whole button on click instead of just one pixel, we only need to amend our click function:

let reductionFactor = 17;
btn.addEventListener("click", e => {
  // Get the color data for our button
  let width = btn.offsetWidth;
  let height = btn.offsetHeight
  let colorData = ctx.getImageData(0, 0, width, height).data;
  
  // Keep track of how many times we've iterated (in order to reduce
  // the total number of particles create)
  let count = 0;
  
  // Go through every location of our button and create a particle
  for(let localX = 0; localX < width; localX++) {
    for(let localY = 0; localY < height; localY++) {
      if(count % reductionFactor === 0) {
        let index = (localY * width + localX) * 4;
        let rgbaColorArr = colorData.slice(index, index + 4);

        let bcr = btn.getBoundingClientRect();
        let globalX = bcr.left + localX;
        let globalY = bcr.top + localY;

        createParticleAtPoint(globalX, globalY, rgbaColorArr);
      }
      count++;
    }
  }
});

See the Pen DOM to Canvas #5 by Zach Saucier (@Zeaklous) on CodePen.

Now the button appears to explode into many tiny particles when clicked.

Hopefully, the web now feels less restrictive than it did at the start of this article now that we know we can use additions (like canvas) to exercise more creative freedom.


We can get even more creative by using edge detection to tell if our element is outside of the bounds of a container (i.e. hidden from view) and create particles when our element goes outside of those bounds.

A whole article could be written on this process because positioning of elements in a web browser is complex, but I’ve created a small plugin called Disintegrate that includes handling for this sort of thing.

Disintegrate: A plugin to create this effect for for you

Disintegrate is open source and handles a lot of the messiness that getting production-ready code for this technique requires. It also allows for multiple elements to apply this effect on the same page, specified containers to be used, a way to ignore specified colors, if needed, and events for the different important moments in the process.

View Repo

Using Disintegrate, we just have to declare data-dis-type="contained" on our button and it will make it so that when our element goes outside its container’s bounds, particles will be created! See the demo for yourself.

Disintegrate’s contain effect on a draggable element.

A different type of effect we can make using Disintegrate is where the container is directly surrounding our element. This allows for self-contained particle animations like the button effect we made earlier. By animating the container and our main element itself, we can create particles in even more interesting ways.

A slide to unlock animation triggers exploding particles at the end.

However, this approach does have its limitations (and so does Disintegrate). For example, it will only work back to IE11 due to the lack of pointer-events support before that (provided Disintegrate is compiled to ES5). Disintegrate also doesn’t support every DOM element we can imagine due to the limitations of html2canvas. The ones that I found most restricting are the incomplete CSS transform support and lack of clip-path support.

To install Disintegrate, you can use npm install disintegrate if you use npm. Or you can manually include html2canvas.js and disintegrate.js before calling disintegrate.init().

Disintegrate is very new and could use some improvements. If you’d like to contribute, both Disintegrate and html2canvas are open source and suggestions are welcome!

How do you think this functionality could be used in your projects? How do you see this approach extending what is possible on the web? Let me know in the comments.