tc about me contact
all blogs

How to Create a Morphing Text Particle Effect with JavaScript and HTML5 Canvas

by Theodore Chan • May 7th, 2023

Are you looking for a way to spice up your website's design? Perhaps you want to add an interactive and visually stunning effect to your landing page. If so, you might be interested in creating a morphing text particle effect using JavaScript and HTML5 Canvas. In this tutorial, we will guide you through the process of creating such an effect from scratch, and we will provide you with the complete code at the end.

Before we start, let's briefly go over what the morphing text particle effect is. The effect consists of a text element that morphs into a swarm of particles that disperse across the canvas, forming a new text element. This effect is achieved by converting the text into particles and then animating the particles to form the new text.

Without further ado, let's dive into the steps to create this effect:

1. Set up the HTML and CSS

First, we need to create a canvas element in our HTML code. We will also add some basic styling to the canvas to ensure it takes up the full screen.

        
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8" />
      <title>Morphing Text Particle Effect</title>
      <style>
        body {
          margin: 0;
          padding: 0;
        }
        canvas {
          display: block;
          position: absolute;
          top: 0;
          left: 0;
        }
      </style>
    </head>
    <body>
      <canvas id="canvas"></canvas>
    </body>
  </html>
        
    

2. Access and set up the Canvas

Next, we will access the canvas element in our JavaScript code and set up the canvas. We will also create a resize event listener to ensure the canvas is always the correct size.

        
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  window.addEventListener("resize", () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    // effect.canvasWidth = canvas.width;
    // effect.canvasHeight = canvas.height;
    // effect.renderText();
  });
        
        

3. Create the Particle Class

We will create a Particle class to represent each particle in the effect. Each particle will have a color, radius, friction, and ease value. Additionally, each particle will have an update() function that continually updates its position based on the mouse's position and its own properties.

        
class Particle {
  constructor(
    effect,
    x,
    y,
    radius,
    color,
    frictionMultiplier,
    easeMultiplier
  ) {
    this.effect = effect;
    this.x =
      (Math.random() * effect.canvasWidth) / 2 + effect.canvasWidth / 4;
    this.y = effect.canvasHeight / 2;
    this.originX = x;
    this.originY = y;
    this.radius = radius;
    this.color = color;
    this.dx = 0;
    this.dy = 0;
    this.vx = 0;
    this.vy = 0;
    this.force = 0;
    this.angle = 0;
    this.distance = 0;
    this.friction = Math.random() * frictionMultiplier;
    this.ease = Math.random() * easeMultiplier + 0.07;
  }
  draw() {
    this.effect.context.fillStyle = this.color;
    this.effect.context.beginPath();
    this.effect.context.arc(
      this.x,
      this.y,
      this.radius,
      0,
      2 * Math.PI,
      false
    );
    this.effect.context.fill();
  }
  update() {
    this.dx = this.effect.mouse.x - this.x;
    this.dy = this.effect.mouse.y - this.y;
    this.distance = Math.sqrt(this.dx * this.dx + this.dy * this.dy);
    this.force = -this.effect.mouse.radius / this.distance;
    if (this.distance < this.effect.mouse.radius) {
      this.angle = Math.atan2(this.dy, this.dx);
      this.vx = this.force * Math.cos(this.angle);
      this.vy = this.force * Math.sin(this.angle);
    }

    this.x +=
      (this.vx *= this.friction) + (this.originX - this.x) * this.ease;
    this.y +=
      (this.vy *= this.friction) + (this.originY - this.y) * this.ease;
  }
}
        
    

This class references the "effect" which we have not implimented yet. We will make that next.

4. Implementing the Morphing Text Particle Effect

We now have everything we need to create the morphing text particle effect. We will start by rendering the text, converting it to particles, and storing those particles in an array.

        
  class Effect {
    constructor(context, canvasWidth, canvasHeight) {
      this.context = context;
      this.canvasWidth = canvasWidth;
      this.canvasHeight = canvasHeight;
      this.particles = [];
      this.particleGap = 4;
      this.mouse = {
        radius: 20000,
        x: undefined,
        y: undefined,
      };
      window.addEventListener("mousemove", (event) => {
        this.mouse.x = event.x;
        this.mouse.y = event.y;
      });
    }

    renderText() {
      this.context.font = "600 96px Mosk";
      this.context.fillText(
        "hello world",
        this.canvasWidth / 2 - 200,
        this.canvasHeight / 2
      );
      this.convertToParticles();
    }

    convertToParticles() {
      this.particles = [];
      const pixles = this.context.getImageData(
        0,
        0,
        this.canvasWidth,
        this.canvasHeight
      );
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      for (let y = 0; y < this.canvasHeight; y += this.particleGap) {
        for (let x = 0; x < this.canvasWidth; x += this.particleGap) {
          const index = (x + y * pixles.width) * 4;
          const alpha = pixles.data[index + 3];
          if (alpha > 0) {
            const red = pixles.data[index + 0];
            const green = pixles.data[index + 1];
            const blue = pixles.data[index + 2];
            const radius = Math.random() * 2 + 1.2;
            const color = `rgb(${red}, ${green}, ${blue})`;
            this.particles.push(
              new Particle(this, x, y, radius, color, 0.007, 0.5)
            );
          }
        }
      }
    }

    morphText(text, fontSize, x, y) {
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      this.context.font = `${fontSize}px Mosk 900`;
      this.context.fillStyle = "rgb(0, 0, 0)";
      this.context.fillText(text, x, y);
      const pixles = this.context.getImageData(
        0,
        0,
        this.canvasWidth,
        this.canvasHeight
      );
      let particlesUsed = 0;
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      for (let y = 0; y < this.canvasHeight; y += this.particleGap) {
        for (let x = 0; x < this.canvasWidth; x += this.particleGap) {
          const index = (x + y * pixles.width) * 4;
          const alpha = pixles.data[index + 3];
          if (alpha > 0) {
            particlesUsed++;
            const red = pixles.data[index + 0];
            const green = pixles.data[index + 1];
            const blue = pixles.data[index + 2];
            const radius = Math.random() * 2 + 1.2;
            const color = `rgb(${red}, ${green}, ${blue})`;
            if (this.particles[particlesUsed] != undefined) {
              this.particles[particlesUsed].color = color;
              this.particles[particlesUsed].originX = x;
              this.particles[particlesUsed].originY = y;
            } else {
              this.particles.push(
                new Particle(this, x, y, radius, color, 0.007, 0.5)
              );
            }
          }
        }
      }

      for (let i = particlesUsed; i < this.particles.length; i++) {
        this.particles[i].color = "rgba(0, 0, 0, 0)";
      }
    }

    render() {
      this.particles.forEach((particle) => {
        particle.update();
        particle.draw();
      });
    }
  }
        
    

5. Creating the Effect

Now that we have the effect class, we can create an instance of it and call the renderText method to render the text and convert it to particles.

      
  const effect = new Effect(ctx, canvas.width, canvas.height);
  effect.renderText();
      
    

Next, we will create an animation loop that will update and draw each particle in the array. We use requestAnimationFrame() to create a loop that will continuously update and draw the particles.

        
  function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    effect.render();
    requestAnimationFrame(animate);
  }
  animate();
        
    

Finally, we can create a setTimout function that morphs the text after 2 seconds to try out the effect.

      
  setTimeout(() => {
    effect.morphText(
      "morphed text",
      100,
      window.innerWidth / 2 - 200,
      window.innerHeight / 2
    );
  }, 2000);
      
    

And Thats It!

You should now have a morphing text particle effect on your canvas. Here's the full code:

      
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;

  window.addEventListener("resize", () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    effect.canvasWidth = canvas.width;
    effect.canvasHeight = canvas.height;
    effect.renderText();
  });

  class Particle {
    constructor(effect, x, y, radius, color, frictionMultiplier, easeMultiplier) {
      this.effect = effect;
      this.x = (Math.random() * effect.canvasWidth) / 2 + effect.canvasWidth / 4;
      this.y = effect.canvasHeight / 2;
      this.originX = x;
      this.originY = y;
      this.radius = radius;
      this.color = color;
      this.dx = 0;
      this.dy = 0;
      this.vx = 0;
      this.vy = 0;
      this.force = 0;
      this.angle = 0;
      this.distance = 0;
      this.friction = Math.random() * frictionMultiplier;
      this.ease = Math.random() * easeMultiplier + 0.07;
    }
    draw() {
      this.effect.context.fillStyle = this.color;
      this.effect.context.beginPath();
      this.effect.context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI, false);
      this.effect.context.fill();
    }
    update() {
      this.dx = this.effect.mouse.x - this.x;
      this.dy = this.effect.mouse.y - this.y;
      this.distance = Math.sqrt(this.dx * this.dx + this.dy * this.dy);
      this.force = -this.effect.mouse.radius / this.distance;
      if (this.distance < this.effect.mouse.radius) {
        this.angle = Math.atan2(this.dy, this.dx);
        this.vx = this.force * Math.cos(this.angle);
        this.vy = this.force * Math.sin(this.angle);
      }

      this.x += (this.vx *= this.friction) + (this.originX - this.x) * this.ease;
      this.y += (this.vy *= this.friction) + (this.originY - this.y) * this.ease;
    }
  }

  class Effect {
    constructor(context, canvasWidth, canvasHeight) {
      this.context = context;
      this.canvasWidth = canvasWidth;
      this.canvasHeight = canvasHeight;
      this.particles = [];
      this.particleGap = 4;
      this.mouse = {
        radius: 20000,
        x: undefined,
        y: undefined,
      };
      window.addEventListener("mousemove", (event) => {
        this.mouse.x = event.x;
        this.mouse.y = event.y;
      });
    }

    renderText() {
      this.context.font = "600 96px Mosk";
      this.context.fillText(
        "hello world",
        this.canvasWidth / 2 - 200,
        this.canvasHeight / 2
      );
      this.convertToParticles();
    }

    convertToParticles() {
      this.particles = [];
      const pixles = this.context.getImageData(
        0,
        0,
        this.canvasWidth,
        this.canvasHeight
      );
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      for (let y = 0; y < this.canvasHeight; y += this.particleGap) {
        for (let x = 0; x < this.canvasWidth; x += this.particleGap) {
          const index = (x + y * pixles.width) * 4;
          const alpha = pixles.data[index + 3];
          if (alpha > 0) {
            const red = pixles.data[index + 0];
            const green = pixles.data[index + 1];
            const blue = pixles.data[index + 2];
            const radius = Math.random() * 2 + 1.2;
            const color = `rgb(${red}, ${green}, ${blue})`;
            this.particles.push(
              new Particle(this, x, y, radius, color, 0.007, 0.5)
            );
          }
        }
      }
    }

    morphText(text, fontSize, x, y) {
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      this.context.font = `${fontSize}px Mosk 900`;
      this.context.fillStyle = "rgb(0, 0, 0)";
      this.context.fillText(text, x, y);
      const pixles = this.context.getImageData(
        0,
        0,
        this.canvasWidth,
        this.canvasHeight
      );
      let particlesUsed = 0;
      this.context.clearRect(0, 0, canvas.width, canvas.height);
      for (let y = 0; y < this.canvasHeight; y += this.particleGap) {
        for (let x = 0; x < this.canvasWidth; x += this.particleGap) {
          const index = (x + y * pixles.width) * 4;
          const alpha = pixles.data[index + 3];
          if (alpha > 0) {
            particlesUsed++;
            const red = pixles.data[index + 0];
            const green = pixles.data[index + 1];
            const blue = pixles.data[index + 2];
            const radius = Math.random() * 2 + 1.2;
            const color = `rgb(${red}, ${green}, ${blue})`;
            if (this.particles[particlesUsed] != undefined) {
              this.particles[particlesUsed].color = color;
              this.particles[particlesUsed].originX = x;
              this.particles[particlesUsed].originY = y;
            } else {
              this.particles.push(
                new Particle(this, x, y, radius, color, 0.007, 0.5)
              );
            }
          }
        }
      }

      for (let i = particlesUsed; i < this.particles.length; i++) {
        this.particles[i].color = "rgba(0, 0, 0, 0)";
      }
    }

    render() {
      this.particles.forEach((particle) => {
        particle.update();
        particle.draw();
      });
    }
  }

  const effect = new Effect(ctx, canvas.width, canvas.height);
  effect.renderText();

  function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    effect.render();

    requestAnimationFrame(animate);
  }
  animate();
  setTimeout(() => {
    effect.morphText(
      "morphed text",
      100,
      window.innerWidth / 2 - 200,
      window.innerHeight / 2
    );
  }, 2000);