· Updated on

How to Create a Beautiful Particle Animation with HTML Canvas

How to Create a Beautiful Particle Animation with HTML Canvas

In this tutorial, we will utilize the power of HTML canvas to create a mesmerizing particle animation resembling a magnificent galaxy of stars. We will delve into the development process behind Stellar, our latest landing page template built with Tailwind CSS.

What sets this animation apart is its interactivity, as the stars will gracefully respond to the movement of your mouse cursor. The interaction will come alive through the magic of JavaScript, allowing us to manipulate the canvas and bring our stellar vision to reality.

We will also demonstrate how to create components with TypeScript support for both Next.js and Vue frameworks, enabling you to integrate this mesmerizing animation seamlessly into your web app.

So let’s dive in and embark on this cosmic journey together.


Create an HTML canvas animation with pure JavaScript

To make things easy and easily understandable, we will start with a simple HTML document where we will reference an external JS file that will contain the code we’re going to write.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Particle Animation</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="https://cdn.tailwindcss.com"></script>
</head>

<body class="font-inter antialiased">

    <main class="relative min-h-screen flex flex-col justify-center bg-slate-900 overflow-hidden">
        <div class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
    
            <!-- Particles animation -->
            <div class="absolute inset-0 pointer-events-none" aria-hidden="true">
                <canvas data-particle-animation></canvas>
            </div>
                
        </div>
    </main>

    <script src="./particle-animation.js"></script>
</body>

</html>

We have created a very simple structure that you can modify according to your preferences and needs.

Here are the important points to know in this step:

  • We have inserted a canvas tag within our HTML document, which will be our reference element for creating the animation. We will add the data-particle-animation attribute to the element, which will be used to invoke the animation from our JS file
  • It is not necessary to define the width and height of the canvas, as the dimensions will be inherited – via JS – from the parent container, which in our case is the div tag with the class absolute inset-0 pointer-events-none
  • We have inserted a script tag within our HTML document, which references the JS file we will write
  • We have used the aria-hidden="true" attribute on the canvas container to hide the animation from screen readers, as it is not relevant for accessibility purposes

Create a JavaScript class for the animation

Now that we have created the HTML structure, we can proceed to write the JS code. First, let’s create a new JavaScript file named particle-animation.js and place it in the same folder as our HTML document.

For our animation, we want to create a JavaScript class called ParticleAnimation, which we will initialize as follows:

new ParticleAnimation(canvas, options);

The class will accept two parameters:

  • canvas, which is our HTML canvas element created in the HTML document
  • options, which we will add in a later phase, is an object that will contain the options for our animation

Let’s create our class:

// Particle animation
class ParticleAnimation {
  constructor(el) {
    this.canvas = el;
    if (!this.canvas) return;
    this.init();
  }

  init() {
    // Initialize canvas
  }
}

// Init ParticleAnimation
const canvasElements = document.querySelectorAll('[data-particle-animation]');
canvasElements.forEach(canvas => {
  new ParticleAnimation(canvas);
});

We have created an init() function that will be called inside the constructor of the class. In this function, we will define all the functionalities we need to initialize the animation.

Also, we have created a forEach loop that allows us to initialize the class for each canvas element found in our HTML document.

Set the canvas size

Firstly, we need to define the dimensions of the canvas, which will be inherited from its container. To achieve this, let’s create a resizeCanvas() method inside our class that will be invoked on page load and whenever the user resizes the browser window.

class ParticleAnimation {
  constructor(el) {
    this.canvas = el;
    if (!this.canvas) return;
    this.canvasContainer = this.canvas.parentElement;
    this.context = this.canvas.getContext('2d');
    this.dpr = window.devicePixelRatio || 1;
    this.canvasSize = {
      w: 0,
      h: 0,
    };
    this.initCanvas = this.initCanvas.bind(this);
    this.resizeCanvas = this.resizeCanvas.bind(this);
    this.init();
  }

  init() {
    this.initCanvas();
    window.addEventListener('resize', this.initCanvas);
  }

  initCanvas() {
    this.resizeCanvas();
  }

  resizeCanvas() {
    this.canvasSize.w = this.canvasContainer.offsetWidth;
    this.canvasSize.h = this.canvasContainer.offsetHeight;
    this.canvas.width = this.canvasSize.w * this.dpr;
    this.canvas.height = this.canvasSize.h * this.dpr;
    this.canvas.style.width = this.canvasSize.w + 'px';
    this.canvas.style.height = this.canvasSize.h + 'px';
    this.context.scale(this.dpr, this.dpr);
  }
}

The resizeCanvas() method calculates the dimensions of the canvas and assigns them to the canvas itself and its container. Additionally, it defines the canvas scale factor, which is necessary to make it sharp on high-resolution screens.

In other words, assuming that the browser window has dimensions of 1400x800px and we are viewing the document on a retina display, this function will transform our canvas element as follows:

<canvas data-particle-animation width="2800" height="1600" style="width: 1400px; height: 800px;"></canvas>

Define the animation options

For our canvas, we want to define some options that allow us to customize the animation. To do this, let’s create a settings object within our constructor and define the default options:

constructor(el, { quantity = 30, staticity = 50, ease = 50 } = {}) {
  this.canvas = el;
  if (!this.canvas) return;
  this.canvasContainer = this.canvas.parentElement;
  this.context = this.canvas.getContext('2d');
  this.dpr = window.devicePixelRatio || 1;
  this.settings = {
    quantity: quantity,
    staticity: staticity,
    ease: ease,
  };
  this.canvasSize = {
    w: 0,
    h: 0,
  };
  this.initCanvas = this.initCanvas.bind(this);
  this.resizeCanvas = this.resizeCanvas.bind(this); 
  this.init();
}

The options we have defined are as follows:

  • quantity the number of particles we want to display
  • staticity the speed of particle movement when the user moves the mouse (a higher value means slower movement)
  • ease the smooth effect we want to apply to the particle movement (a higher value means smoother movement)

Additionally, let’s adjust how we invoke the class by adding the options:

const canvasElements = document.querySelectorAll('[data-particle-animation]');
canvasElements.forEach(canvas => {
  const options = {
    quantity: canvas.dataset.particleQuantity,
    staticity: canvas.dataset.particleStaticity,
    ease: canvas.dataset.particleEase,
  };
  new ParticleAnimation(canvas, options);
});

This way, we can define the options directly in our HTML document using the data-* attributes. For example, if we want to modify the number of particles, we can add the data-particle-quantity attribute to our canvas element:

<canvas data-particle-animation data-particle-quantity="5"></canvas>

In the example above, we have set the number of particles to 5. Similarly, we can modify the staticity and ease options using the data-particle-staticity and data-particle-ease attributes, respectively.

Draw the particles

It’s time to create the particles in our canvas. To do this, let’s start by creating an empty array this.circles = [] inside our constructor. This array will hold all the particles we create.

Next, let’s create a method called circleParams(), which will allow us to define the properties of each particle:

circleParams() {
  const x = Math.floor(Math.random() * this.canvasSize.w);
  const y = Math.floor(Math.random() * this.canvasSize.h);
  const translateX = 0;
  const translateY = 0;
  const size = Math.floor(Math.random() * 2) + 1;
  const alpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
  const dx = (Math.random() - 0.5) * 0.2;
  const dy = (Math.random() - 0.5) * 0.2;
  const magnetism = 0.1 + Math.random() * 4;
  return { x, y, translateX, translateY, size, alpha, dx, dy, magnetism };
}

This function allows us to randomly define:

  • The size of the particles (between 1 and 3 pixels in diameter)
  • The position of the particles (between 0 and the width/height of the canvas)
  • The opacity of the particles (between 0.1 and 0.7)
  • The movement speed of the particles (between -0.1 and 0.1)
  • The attraction force of the particles (between 0.1 and 4)

These values, defined as { x, y, translateX, translateY, size, alpha, dx, dy, magnetism }, will then be passed to another function responsible for drawing the particles on the canvas.

This function is called drawCircle() and is defined as follows:

drawCircle(circle, update = false) {
  const { x, y, translateX, translateY, size, alpha } = circle;
  this.context.translate(translateX, translateY);
  this.context.beginPath();
  this.context.arc(x, y, size, 0, 2 * Math.PI);
  this.context.fillStyle = `rgba(255, 255, 255, ${alpha})`;
  this.context.fill();
  this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
  if (!update) {
    this.circles.push(circle);
  }
}

We won’t go into detail about everything this function does. For now, it’s enough to know that this function draws a circle on the canvas using the properties we defined earlier.

The last step is to create a method called drawParticles(), which will be responsible for invoking the drawCircle() function for each particle we have defined:

drawParticles() {
  const particleCount = this.settings.quantity;
  for (let i = 0; i < particleCount; i++) {
    const circle = this.circleParams();
    this.drawCircle(circle);
  }
}

At this point in the tutorial, we have created a canvas and drawn static particles inside it. Here’s the complete code we have written so far:

// Particle animation
class ParticleAnimation {
  constructor(el, { quantity = 30, staticity = 50, ease = 50 } = {}) {
    this.canvas = el;
    if (!this.canvas) return;
    this.canvasContainer = this.canvas.parentElement;
    this.context = this.canvas.getContext('2d');
    this.dpr = window.devicePixelRatio || 1;
    this.settings = {
      quantity: quantity,
      staticity: staticity,
      ease: ease,
    };
    this.circles = [];    
    this.canvasSize = {
      w: 0,
      h: 0,
    };
    this.initCanvas = this.initCanvas.bind(this);
    this.resizeCanvas = this.resizeCanvas.bind(this);
    this.drawCircle = this.drawCircle.bind(this);
    this.drawParticles = this.drawParticles.bind(this);    
    this.init();
  }

  init() {
    this.initCanvas();
    window.addEventListener('resize', this.initCanvas);
  }

  initCanvas() {
    this.resizeCanvas();
    this.drawParticles();
  }

  resizeCanvas() {
    this.circles.length = 0;
    this.canvasSize.w = this.canvasContainer.offsetWidth;
    this.canvasSize.h = this.canvasContainer.offsetHeight;
    this.canvas.width = this.canvasSize.w * this.dpr;
    this.canvas.height = this.canvasSize.h * this.dpr;
    this.canvas.style.width = this.canvasSize.w + 'px';
    this.canvas.style.height = this.canvasSize.h + 'px';
    this.context.scale(this.dpr, this.dpr);
  }

  circleParams() {
    const x = Math.floor(Math.random() * this.canvasSize.w);
    const y = Math.floor(Math.random() * this.canvasSize.h);
    const translateX = 0;
    const translateY = 0;
    const size = Math.floor(Math.random() * 2) + 1;
    const alpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
    const dx = (Math.random() - 0.5) * 0.2;
    const dy = (Math.random() - 0.5) * 0.2;
    const magnetism = 0.1 + Math.random() * 4;
    return { x, y, translateX, translateY, size, alpha, dx, dy, magnetism };
  }

  drawCircle(circle, update = false) {
    const { x, y, translateX, translateY, size, alpha } = circle;
    this.context.translate(translateX, translateY);
    this.context.beginPath();
    this.context.arc(x, y, size, 0, 2 * Math.PI);
    this.context.fillStyle = `rgba(255, 255, 255, ${alpha})`;
    this.context.fill();
    this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    if (!update) {
      this.circles.push(circle);
    }
  }

  drawParticles() {
    const particleCount = this.settings.quantity;
    for (let i = 0; i < particleCount; i++) {
      const circle = this.circleParams();
      this.drawCircle(circle);
    }
  }  
}

// Init ParticleAnimation
const canvasElements = document.querySelectorAll('[data-particle-animation]');
canvasElements.forEach(canvas => {
  const options = {
    quantity: canvas.dataset.particleQuantity,
    staticity: canvas.dataset.particleStaticity,
    ease: canvas.dataset.particleEase,
  };
  new ParticleAnimation(canvas, options);
});

Note that this.circles.length = 0; has been added inside the resizeCanvas() method. This allows us to empty the circles array each time the canvas is resized, preventing the particle count from multiplying with each resize.

As we previously said, the particles are currently static, meaning they don’t move in space, and there’s no interaction effect with the mouse. To achieve this, we need to add some more code.

Animate the particles

Now things get a bit more complicated, but don’t worry, we’ll explain step by step what needs to be done. For the animation, we will create a method called animate(), and we will use requestAnimationFrame() inside it to execute the function at each animation frame.

animate() {
  // Things to be done ...
  window.requestAnimationFrame(this.animate);
}

This way, the function will run in a loop, and we can update the properties of the particles on each frame. For each frame, we need to clear the canvas first and then redraw the particles. To do this, let’s create a new method called clearContext():

clearContext() {
  this.context.clearRect(0, 0, this.canvasSize.w, this.canvasSize.h);
}

And let’s call it inside the animate() method:

animate() {
  this.clearContext();
  window.requestAnimationFrame(this.animate);
}

Then, let’s loop through the circles array and redraw each particle on every frame:

animate() {
  this.clearContext();
  this.circles.forEach((circle, i) => {
    // Handle the alpha value
    const edge = [
      circle.x + circle.translateX - circle.size, // distance from left edge
      this.canvasSize.w - circle.x - circle.translateX - circle.size, // distance from right edge
      circle.y + circle.translateY - circle.size, // distance from top edge
      this.canvasSize.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
    ];
    const closestEdge = edge.reduce((a, b) => Math.min(a, b));
    const remapClosestEdge = this.remapValue(closestEdge, 0, 20, 0, 1).toFixed(2);
    if (remapClosestEdge > 1) {
      circle.alpha += 0.02;
      if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha;
    } else {
      circle.alpha = circle.targetAlpha * remapClosestEdge;
    }
    circle.x += circle.dx;
    circle.y += circle.dy;
    // circle gets out of the canvas
    if (circle.x < -circle.size || circle.x > this.canvasSize.w + circle.size || circle.y < -circle.size || circle.y > this.canvasSize.h + circle.size) {
      // remove the circle from the array
      this.circles.splice(i, 1);
      // create a new circle
      const circle = this.circleParams();
      this.drawCircle(circle);
    // update the circle position
    } else {
      this.drawCircle({ ...circle, x: circle.x, y: circle.y, translateX: circle.translateX, translateY: circle.translateY, alpha: circle.alpha }, true);
    }
  });
  window.requestAnimationFrame(this.animate);
}

The first thing we’re doing in the animate() function is handling the opacity of each particle. By default, particles have an opacity of 0 on page load, and on each frame, it increases by 0.02 until it reaches the targetAlpha value.

Additionally, we want the opacity of the particles to decrease as they get closer to the edges of the canvas. So when the distance from the edge is less than 20px, the opacity of the particle gradually starts to decrease using the remapValue() function, which remaps the closestEdge value from 0 to 20 (distance from the edge) to 0 to 1 (particle opacity):

remapValue(value, start1, end1, start2, end2) {
  const remapped = (value - start1) * (end2 - start2) / (end1 - start1) + start2;
  return remapped > 0 ? remapped : 0;
}

In the last part of the animate() method, we update the position of each particle, and if a particle goes outside the canvas, we remove it from the circles array and create a new one. If the particle is still within the canvas, we redraw it using the drawCircle() method.

Fantastic! We have created an animation of particles moving in space on their own. Now, we want these particles to be attracted to the mouse cursor.

Add mouse interaction

To add mouse interaction, we first need to know its coordinates relative to the canvas. Let’s start by adding a mouse property to the constructor, which will hold the coordinates:

this.mouse = {
  x: 0,
  y: 0,
};

Next, let’s create an onMouseMove() method that updates the coordinates when the mouse moves:

init() {
  this.initCanvas();
  this.animate();
  window.addEventListener('resize', this.initCanvas);
  window.addEventListener('mousemove', this.onMouseMove);
}

Great! At this point, we want the particles to be attracted to the mouse. While we used the x and y properties for the natural movement of the particles, we’ll use the translateX and translateY properties for the mouse attraction. This way, the particles will move independently of their natural movement and won’t lose their trajectory.

Let’s update the animate() method to ensure that we update the position of each particle based on its distance from the mouse. The value added (or subtracted) to the translateX and translateY properties will be the distance from the mouse: particles closer to the mouse will move faster than those farther away. Particles movement will be affected by staticity, magnetism and ease parameters too.

circle.translateX += ((this.mouse.x / (this.settings.staticity / circle.magnetism)) - circle.translateX) / this.settings.ease;
circle.translateY += ((this.mouse.y / (this.settings.staticity / circle.magnetism)) - circle.translateY) / this.settings.ease;

Reassembling the code

And here is the final JS code for our canvas animation:

// Particle animation
class ParticleAnimation {
  constructor(el, { quantity = 30, staticity = 50, ease = 50 } = {}) {
    this.canvas = el;
    if (!this.canvas) return;
    this.canvasContainer = this.canvas.parentElement;
    this.context = this.canvas.getContext('2d');
    this.dpr = window.devicePixelRatio || 1;
    this.settings = {
      quantity: quantity,
      staticity: staticity,
      ease: ease,
    };
    this.circles = [];
    this.mouse = {
      x: 0,
      y: 0,
    };
    this.canvasSize = {
      w: 0,
      h: 0,
    };
    this.onMouseMove = this.onMouseMove.bind(this);
    this.initCanvas = this.initCanvas.bind(this);
    this.resizeCanvas = this.resizeCanvas.bind(this);
    this.drawCircle = this.drawCircle.bind(this);
    this.drawParticles = this.drawParticles.bind(this);
    this.remapValue = this.remapValue.bind(this);
    this.animate = this.animate.bind(this);
    this.init();
  }

  init() {
    this.initCanvas();
    this.animate();
    window.addEventListener('resize', this.initCanvas);
    window.addEventListener('mousemove', this.onMouseMove);
  }

  initCanvas() {
    this.resizeCanvas();
    this.drawParticles();
  }

  onMouseMove(event) {
    const { clientX, clientY } = event;
    const rect = this.canvas.getBoundingClientRect();
    const { w, h } = this.canvasSize;
    const x = clientX - rect.left - (w / 2);
    const y = clientY - rect.top - (h / 2);
    const inside = x < (w / 2) && x > -(w / 2) && y < (h / 2) && y > -(h / 2);
    if (inside) {
      this.mouse.x = x;
      this.mouse.y = y;
    }
  }

  resizeCanvas() {
    this.circles.length = 0;
    this.canvasSize.w = this.canvasContainer.offsetWidth;
    this.canvasSize.h = this.canvasContainer.offsetHeight;
    this.canvas.width = this.canvasSize.w * this.dpr;
    this.canvas.height = this.canvasSize.h * this.dpr;
    this.canvas.style.width = this.canvasSize.w + 'px';
    this.canvas.style.height = this.canvasSize.h + 'px';
    this.context.scale(this.dpr, this.dpr);
  }

  circleParams() {
    const x = Math.floor(Math.random() * this.canvasSize.w);
    const y = Math.floor(Math.random() * this.canvasSize.h);
    const translateX = 0;
    const translateY = 0;
    const size = Math.floor(Math.random() * 2) + 1;
    const alpha = 0;
    const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
    const dx = (Math.random() - 0.5) * 0.2;
    const dy = (Math.random() - 0.5) * 0.2;
    const magnetism = 0.1 + Math.random() * 4;
    return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism };
  }

  drawCircle(circle, update = false) {
    const { x, y, translateX, translateY, size, alpha } = circle;
    this.context.translate(translateX, translateY);
    this.context.beginPath();
    this.context.arc(x, y, size, 0, 2 * Math.PI);
    this.context.fillStyle = `rgba(255, 255, 255, ${alpha})`;
    this.context.fill();
    this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    if (!update) {
      this.circles.push(circle);
    }
  }

  clearContext() {
    this.context.clearRect(0, 0, this.canvasSize.w, this.canvasSize.h);
  }

  drawParticles() {
    this.clearContext();
    const particleCount = this.settings.quantity;
    for (let i = 0; i < particleCount; i++) {
      const circle = this.circleParams();
      this.drawCircle(circle);
    }
  }

  // This function remaps a value from one range to another range
  remapValue(value, start1, end1, start2, end2) {
    const remapped = (value - start1) * (end2 - start2) / (end1 - start1) + start2;
    return remapped > 0 ? remapped : 0;
  }

  animate() {
    this.clearContext();
    this.circles.forEach((circle, i) => {
      // Handle the alpha value
      const edge = [
        circle.x + circle.translateX - circle.size, // distance from left edge
        this.canvasSize.w - circle.x - circle.translateX - circle.size, // distance from right edge
        circle.y + circle.translateY - circle.size, // distance from top edge
        this.canvasSize.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
      ];
      const closestEdge = edge.reduce((a, b) => Math.min(a, b));
      const remapClosestEdge = this.remapValue(closestEdge, 0, 20, 0, 1).toFixed(2);
      if (remapClosestEdge > 1) {
        circle.alpha += 0.02;
        if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha;
      } else {
        circle.alpha = circle.targetAlpha * remapClosestEdge;
      }
      circle.x += circle.dx;
      circle.y += circle.dy;
      circle.translateX += ((this.mouse.x / (this.settings.staticity / circle.magnetism)) - circle.translateX) / this.settings.ease;
      circle.translateY += ((this.mouse.y / (this.settings.staticity / circle.magnetism)) - circle.translateY) / this.settings.ease;
      // circle gets out of the canvas
      if (circle.x < -circle.size || circle.x > this.canvasSize.w + circle.size || circle.y < -circle.size || circle.y > this.canvasSize.h + circle.size) {
        // remove the circle from the array
        this.circles.splice(i, 1);
        // create a new circle
        const circle = this.circleParams();
        this.drawCircle(circle);
        // update the circle position
      } else {
        this.drawCircle({ ...circle, x: circle.x, y: circle.y, translateX: circle.translateX, translateY: circle.translateY, alpha: circle.alpha }, true);
      }
    });
    window.requestAnimationFrame(this.animate);
  }
}

// Init ParticleAnimation
const canvasElements = document.querySelectorAll('[data-particle-animation]');
canvasElements.forEach(canvas => {
  const options = {
    quantity: canvas.dataset.particleQuantity,
    staticity: canvas.dataset.particleStaticity,
    ease: canvas.dataset.particleEase,
  };
  new ParticleAnimation(canvas, options);
});

As previously mentioned, you just need to include the above code in a JavaScript file and include it in your project. Then, you can use particle animations anywhere on the HTML page by simply adding a data-particle-animation attribute to the canvas tag, and optionally specifying other desired parameters. For example:

<canvas data-particle-animation data-particle-quantity="5" data-particle-staticity="40" data-particle-ease="60"></canvas>

The above code will create a canvas element with a particle animation that will have 5 particles, a staticity of 40, and an ease of 60.


Create a Next.js particle animation component

Now let’s see how to create a React component for this particle animation that can be used in a Next.js app. We want to create a component that integrates the JavaScript functionality (but with TypeScript support) and is reusable, allowing optional parameters to be passed as props.

The component we are about to build is available in our GitHub repository that collects all Next.js tutorials.

Create the component structure

Create a new file called particles.tsx in the components folder of our Next.js project. Let’s set up the structure where we will later insert the code created in the previous sections.

'use client'

import React, { useRef, useEffect } from 'react'

interface ParticlesProps {
  className?: string
  quantity?: number
  staticity?: number
  ease?: number,
}

export default function Particles({
  className = '',
  quantity = 30,
  staticity = 50,
  ease = 50,
}: ParticlesProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const canvasContainerRef = useRef<HTMLDivElement>(null)
  const context = useRef<CanvasRenderingContext2D | null>(null)
  const circles = useRef<any[]>([])
  const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
  const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
  const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1

  return (
    <div className={className} ref={canvasContainerRef} aria-hidden="true">
      <canvas ref={canvasRef} />
    </div>
  )

}

The important things to note in this first step are as follows:

  • We have defined the props that the component accepts (className, quantity, staticity, and ease) with default values
  • We have defined the same const variables that we used in the JavaScript code, with useRef
  • Since we are using TypeScript, we have defined an interface for the props to provide type checking
  • The component returns not only the canvas but also the wrapping <div> element, from which it inherits the dimensions and to which we can pass desired CSS classes

Create a new component for mouse position tracking

To obtain the mouse position within the client, we will create a new component specifically designed to return the mouse coordinates. This component will enhance modularity and reusability. Let’s create a new file called useMousePosition.ts and insert the following code:

import { useState, useEffect } from 'react';

interface MousePosition {
  x: number;
  y: number;
}

export default function useMousePosition(): MousePosition {
  const [mousePosition, setMousePosition] = useState<MousePosition>({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      setMousePosition({ x: event.clientX, y: event.clientY });
    }

    window.addEventListener('mousemove', handleMouseMove);

    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    }
  }, []);

  return mousePosition;
}

This component is very simple but quite useful. Essentially, whenever the mouse moves within the client, the component returns the mouse coordinates. Additionally, the component takes care of removing the event listener when unmounted.

Once we import the MousePosition component into our Particles component using import useMousePosition from './utils/useMousePosition', we can define a new constant variable that will hold the mouse coordinates. We can then use this variable as a dependency in the watch function that executes the onMouseMove function.

In simpler terms, the onMouseMove function will be executed every time the mouse moves.

const mousePosition = useMousePosition()

useEffect(() => {
  onMouseMove()
}, [mousePosition.x, mousePosition.y])

Add the component logic

Let’s proceed with adding the previously defined methods from the JavaScript class to the component. It’s a simple copy and paste, but with some modifications. For example, in the React component, each function is defined as a const variable instead of a class method. We have also added TypeScript annotations:

const initCanvas = () => {
  resizeCanvas()
  drawParticles()
}

const onMouseMove = () => {
  if (canvasRef.current) {
    const rect = canvasRef.current.getBoundingClientRect()
    const { w, h } = canvasSize.current
    const x = mousePosition.x - rect.left - w / 2
    const y = mousePosition.y - rect.top - h / 2
    const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
    if (inside) {
      mouse.current.x = x
      mouse.current.y = y
    }
  }
}

const resizeCanvas = () => {
  if (canvasContainerRef.current && canvasRef.current && context.current) {
    circles.current.length = 0
    canvasSize.current.w = canvasContainerRef.current.offsetWidth
    canvasSize.current.h = canvasContainerRef.current.offsetHeight
    canvasRef.current.width = canvasSize.current.w * dpr
    canvasRef.current.height = canvasSize.current.h * dpr
    canvasRef.current.style.width = canvasSize.current.w + 'px'
    canvasRef.current.style.height = canvasSize.current.h + 'px'
    context.current.scale(dpr, dpr)
  }
}

type Circle = {
  x: number
  y: number
  translateX: number
  translateY: number
  size: number
  alpha: number
  targetAlpha: number
  dx: number
  dy: number
  magnetism: number
}  

const circleParams = (): Circle => {
  const x = Math.floor(Math.random() * canvasSize.current.w)
  const y = Math.floor(Math.random() * canvasSize.current.h)
  const translateX = 0
  const translateY = 0
  const size = Math.floor(Math.random() * 2) + 1
  const alpha = 0
  const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
  const dx = (Math.random() - 0.5) * 0.2
  const dy = (Math.random() - 0.5) * 0.2
  const magnetism = 0.1 + Math.random() * 4
  return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism }
}

const drawCircle = (circle: Circle, update = false) => {
  if (context.current) {
    const { x, y, translateX, translateY, size, alpha } = circle
    context.current.translate(translateX, translateY)
    context.current.beginPath()
    context.current.arc(x, y, size, 0, 2 * Math.PI)
    context.current.fillStyle = `rgba(255, 255, 255, ${alpha})`
    context.current.fill()
    context.current.setTransform(dpr, 0, 0, dpr, 0, 0)

    if (!update) {
      circles.current.push(circle)
    }
  }
}

const clearContext = () => {
  if (context.current) {
    context.current.clearRect(0, 0, canvasSize.current.w, canvasSize.current.h)
  }
}

const drawParticles = () => {
  clearContext()
  const particleCount = quantity
  for (let i = 0; i < particleCount; i++) {
    const circle = circleParams()
    drawCircle(circle)
  }
}

const remapValue = (
  value: number,
  start1: number,
  end1: number,
  start2: number,
  end2: number
): number => {
  const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
  return remapped > 0 ? remapped : 0
}

const animate = () => {
  clearContext()
  circles.current.forEach((circle: Circle, i: number) => {
    // Handle the alpha value
    const edge = [
      circle.x + circle.translateX - circle.size, // distance from left edge
      canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
      circle.y + circle.translateY - circle.size, // distance from top edge
      canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
    ]
    const closestEdge = edge.reduce((a, b) => Math.min(a, b))
    const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2))
    if (remapClosestEdge > 1) {
      circle.alpha += 0.02
      if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha
    } else {
      circle.alpha = circle.targetAlpha * remapClosestEdge
    }
    circle.x += circle.dx
    circle.y += circle.dy
    circle.translateX += ((mouse.current.x / (staticity / circle.magnetism)) - circle.translateX) / ease
    circle.translateY += ((mouse.current.y / (staticity / circle.magnetism)) - circle.translateY) / ease
    // circle gets out of the canvas
    if (
      circle.x < -circle.size ||
      circle.x > canvasSize.current.w + circle.size ||
      circle.y < -circle.size ||
      circle.y > canvasSize.current.h + circle.size
    ) {
      // remove the circle from the array
      circles.current.splice(i, 1)
      // create a new circle
      const newCircle = circleParams()
      drawCircle(newCircle)
      // update the circle position
    } else {
      drawCircle(
        {
          ...circle,
          x: circle.x,
          y: circle.y,
          translateX: circle.translateX,
          translateY: circle.translateY,
          alpha: circle.alpha,
        },
        true
      )
    }
  })
  window.requestAnimationFrame(animate)
}

Finally, we use the useEffect hook to initialize the canvas, animate it, and update it whenever the window size changes.

useEffect(() => {    
  if (canvasRef.current) {
    context.current = canvasRef.current.getContext('2d')
  }
  initCanvas()
  animate()
  window.addEventListener('resize', initCanvas)

  return () => {
    window.removeEventListener('resize', initCanvas)
  }
}, [])

Great, the Next.js component is complete! Here’s how it looks:

'use client'

import React, { useRef, useEffect } from 'react'
import useMousePosition from './utils/useMousePosition'

interface ParticlesProps {
  className?: string
  quantity?: number
  staticity?: number
  ease?: number
}

export default function Particles({
  className = '',
  quantity = 30,
  staticity = 50,
  ease = 50,
}: ParticlesProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const canvasContainerRef = useRef<HTMLDivElement>(null)
  const context = useRef<CanvasRenderingContext2D | null>(null)
  const circles = useRef<any[]>([])
  const mousePosition = useMousePosition()
  const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
  const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
  const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1

  useEffect(() => {    
    if (canvasRef.current) {
      context.current = canvasRef.current.getContext('2d')
    }
    initCanvas()
    animate()
    window.addEventListener('resize', initCanvas)

    return () => {
      window.removeEventListener('resize', initCanvas)
    }
  }, [])

  useEffect(() => {
    onMouseMove()
  }, [mousePosition.x, mousePosition.y])

  const initCanvas = () => {
    resizeCanvas()
    drawParticles()
  }

  const onMouseMove = () => {
    if (canvasRef.current) {
      const rect = canvasRef.current.getBoundingClientRect()
      const { w, h } = canvasSize.current
      const x = mousePosition.x - rect.left - w / 2
      const y = mousePosition.y - rect.top - h / 2
      const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
      if (inside) {
        mouse.current.x = x
        mouse.current.y = y
      }
    }
  }

  const resizeCanvas = () => {
    if (canvasContainerRef.current && canvasRef.current && context.current) {
      circles.current.length = 0
      canvasSize.current.w = canvasContainerRef.current.offsetWidth
      canvasSize.current.h = canvasContainerRef.current.offsetHeight
      canvasRef.current.width = canvasSize.current.w * dpr
      canvasRef.current.height = canvasSize.current.h * dpr
      canvasRef.current.style.width = canvasSize.current.w + 'px'
      canvasRef.current.style.height = canvasSize.current.h + 'px'
      context.current.scale(dpr, dpr)
    }
  }

  type Circle = {
    x: number
    y: number
    translateX: number
    translateY: number
    size: number
    alpha: number
    targetAlpha: number
    dx: number
    dy: number
    magnetism: number
  }  

  const circleParams = (): Circle => {
    const x = Math.floor(Math.random() * canvasSize.current.w)
    const y = Math.floor(Math.random() * canvasSize.current.h)
    const translateX = 0
    const translateY = 0
    const size = Math.floor(Math.random() * 2) + 1
    const alpha = 0
    const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
    const dx = (Math.random() - 0.5) * 0.2
    const dy = (Math.random() - 0.5) * 0.2
    const magnetism = 0.1 + Math.random() * 4
    return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism }
  }

  const drawCircle = (circle: Circle, update = false) => {
    if (context.current) {
      const { x, y, translateX, translateY, size, alpha } = circle
      context.current.translate(translateX, translateY)
      context.current.beginPath()
      context.current.arc(x, y, size, 0, 2 * Math.PI)
      context.current.fillStyle = `rgba(255, 255, 255, ${alpha})`
      context.current.fill()
      context.current.setTransform(dpr, 0, 0, dpr, 0, 0)

      if (!update) {
        circles.current.push(circle)
      }
    }
  }

  const clearContext = () => {
    if (context.current) {
      context.current.clearRect(0, 0, canvasSize.current.w, canvasSize.current.h)
    }
  }

  const drawParticles = () => {
    clearContext()
    const particleCount = quantity
    for (let i = 0; i < particleCount; i++) {
      const circle = circleParams()
      drawCircle(circle)
    }
  }

  const remapValue = (
    value: number,
    start1: number,
    end1: number,
    start2: number,
    end2: number
  ): number => {
    const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
    return remapped > 0 ? remapped : 0
  }

  const animate = () => {
    clearContext()
    circles.current.forEach((circle: Circle, i: number) => {
      // Handle the alpha value
      const edge = [
        circle.x + circle.translateX - circle.size, // distance from left edge
        canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
        circle.y + circle.translateY - circle.size, // distance from top edge
        canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
      ]
      const closestEdge = edge.reduce((a, b) => Math.min(a, b))
      const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2))
      if (remapClosestEdge > 1) {
        circle.alpha += 0.02
        if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha
      } else {
        circle.alpha = circle.targetAlpha * remapClosestEdge
      }
      circle.x += circle.dx
      circle.y += circle.dy
      circle.translateX += ((mouse.current.x / (staticity / circle.magnetism)) - circle.translateX) / ease
      circle.translateY += ((mouse.current.y / (staticity / circle.magnetism)) - circle.translateY) / ease
      // circle gets out of the canvas
      if (
        circle.x < -circle.size ||
        circle.x > canvasSize.current.w + circle.size ||
        circle.y < -circle.size ||
        circle.y > canvasSize.current.h + circle.size
      ) {
        // remove the circle from the array
        circles.current.splice(i, 1)
        // create a new circle
        const newCircle = circleParams()
        drawCircle(newCircle)
        // update the circle position
      } else {
        drawCircle(
          {
            ...circle,
            x: circle.x,
            y: circle.y,
            translateX: circle.translateX,
            translateY: circle.translateY,
            alpha: circle.alpha,
          },
          true
        )
      }
    })
    window.requestAnimationFrame(animate)
  }

  return (
    <div className={className} ref={canvasContainerRef} aria-hidden="true">
      <canvas ref={canvasRef} />
    </div>
  )
}

You can import and use it like a regular React component. For example:

<Particles className="absolute inset-0 pointer-events-none" quantity={50} />

Creating a similar component for Vue

Let’s now see how we can create a perfect clone of the Next.js component for Vue. First, let’s create a new Vue component.

The component we are about to create is available in our GitHub repository, which includes all the examples from the Cruip tutorials.

Vue component setup

We will be using the Composition API, script setup syntax, and TypeScript. So, create a file called Particles.vue and set up the component structure as follows:

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, reactive, watch } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLDivElement | null>(null)
const context = ref<CanvasRenderingContext2D | null>(null)
const circles = ref<any[]>([])
const mouse = reactive<{ x: number; y: number }>({ x: 0, y: 0 })
const canvasSize = reactive<{ w: number; h: number }>({ w: 0, h: 0 })
const dpr = window.devicePixelRatio || 1

interface Props {
  quantity?: number
  staticity?: number
  ease?: number
}

const props = withDefaults(defineProps<Props>(), {
  quantity: 30,
  staticity: 50,
  ease: 50,
})
</script>


<template>
  <div ref="canvasContainerRef" aria-hidden="true">
    <canvas ref="canvasRef"></canvas>
  </div>
</template>

As in the React version, we have created a functional component that accepts the props quantity, staticity, and ease. We have also defined the same const variables used in the JavaScript code, but in this case, we have defined them as ref variables so that we can use them within the Vue component.

Create a helper function for mouse tracking

We don’t have to come up with anything new. We can simply adapt the code from the React component to create a Vue component that allows us to track the mouse movement. Let’s create a new file called MousePosition.ts and copy the following code into it:

import { ref, onMounted, onBeforeUnmount } from 'vue'

export default function useMousePosition() {
  const mousePosition = ref < { x: number; y: number } > ({ x: 0, y: 0 })

  const handleMouseMove = (event: MouseEvent) => {
    mousePosition.value = { x: event.clientX, y: event.clientY }
  }

  onMounted(() => {
    window.addEventListener('mousemove', handleMouseMove)
  })

  onBeforeUnmount(() => {
    window.removeEventListener('mousemove', handleMouseMove)
  })

  return mousePosition
}

This component can be imported in Particles.vue and used to trigger other functions whenever the mouse moves, using a watcher. For example:

import useMousePosition from './utils/MousePosition'

const mousePosition = useMousePosition()

watch(
  () => mousePosition.value,
  () => {
    onMouseMove()
  }
)

Complete the Vue Component

Now we just need to complete the Vue component by adding the functions with TypeScript annotations:

const initCanvas = () => {
  resizeCanvas()
  drawParticles()
}

const onMouseMove = () => {
  if (canvasRef.value) {
    const rect = canvasRef.value.getBoundingClientRect()
    const { w, h } = canvasSize
    const x = mousePosition.value.x - rect.left - w / 2
    const y = mousePosition.value.y - rect.top - h / 2
    const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
    if (inside) {
      mouse.x = x
      mouse.y = y
    }
  }
}

const resizeCanvas = () => {
  if (canvasContainerRef.value && canvasRef.value && context.value) {
    circles.value.length = 0
    canvasSize.w = canvasContainerRef.value.offsetWidth
    canvasSize.h = canvasContainerRef.value.offsetHeight
    canvasRef.value.width = canvasSize.w * dpr
    canvasRef.value.height = canvasSize.h * dpr
    canvasRef.value.style.width = canvasSize.w + 'px'
    canvasRef.value.style.height = canvasSize.h + 'px'
    context.value.scale(dpr, dpr)
  }
}

type Circle = {
  x: number
  y: number
  translateX: number
  translateY: number
  size: number
  alpha: number
  targetAlpha: number
  dx: number
  dy: number
  magnetism: number
}

const circleParams = (): Circle => {
  const x = Math.floor(Math.random() * canvasSize.w)
  const y = Math.floor(Math.random() * canvasSize.h)
  const translateX = 0
  const translateY = 0
  const size = Math.floor(Math.random() * 2) + 1
  const alpha = 0
  const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
  const dx = (Math.random() - 0.5) * 0.2
  const dy = (Math.random() - 0.5) * 0.2
  const magnetism = 0.1 + Math.random() * 4
  return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism }
}

const drawCircle = (circle: Circle, update = false) => {
  if (context.value) {
    const { x, y, translateX, translateY, size, alpha } = circle
    context.value.translate(translateX, translateY)
    context.value.beginPath()
    context.value.arc(x, y, size, 0, 2 * Math.PI)
    context.value.fillStyle = `rgba(255, 255, 255, ${alpha})`
    context.value.fill()
    context.value.setTransform(dpr, 0, 0, dpr, 0, 0)

    if (!update) {
      circles.value.push(circle)
    }
  }
}

const clearContext = () => {
  if (context.value) {
    context.value.clearRect(0, 0, canvasSize.w, canvasSize.h)
  }
}

const drawParticles = () => {
  clearContext()
  const particleCount = props.quantity
  for (let i = 0; i < particleCount; i++) {
    const circle = circleParams()
    drawCircle(circle)
  }
}

const remapValue = (
  value: number,
  start1: number,
  end1: number,
  start2: number,
  end2: number
): number => {
  const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
  return remapped > 0 ? remapped : 0
}

const animate = () => {
  clearContext()
  circles.value.forEach((circle, i) => {
    // Handle the alpha value
    const edge = [
      circle.x + circle.translateX - circle.size, // distance from left edge
      canvasSize.w - circle.x - circle.translateX - circle.size, // distance from right edge
      circle.y + circle.translateY - circle.size, // distance from top edge
      canvasSize.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
    ]
    const closestEdge = edge.reduce((a, b) => Math.min(a, b))
    const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2))
    if (remapClosestEdge > 1) {
      circle.alpha += 0.02
      if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha
    } else {
      circle.alpha = circle.targetAlpha * remapClosestEdge
    }
    circle.x += circle.dx
    circle.y += circle.dy
    circle.translateX += ((mouse.x / (props.staticity / circle.magnetism)) - circle.translateX) / props.ease
    circle.translateY += ((mouse.y / (props.staticity / circle.magnetism)) - circle.translateY) / props.ease
    // circle gets out of the canvas
    if (
      circle.x < -circle.size ||
      circle.x > canvasSize.w + circle.size ||
      circle.y < -circle.size ||
      circle.y > canvasSize.h + circle.size
    ) {
      // remove the circle from the array
      circles.value.splice(i, 1)
      // create a new circle
      const newCircle = circleParams()
      drawCircle(newCircle)
      // update the circle position
    } else {
      drawCircle(
        {
          ...circle,
          x: circle.x,
          y: circle.y,
          translateX: circle.translateX,
          translateY: circle.translateY,
          alpha: circle.alpha,
        },
        true
      )
    }
  })
  window.requestAnimationFrame(animate)
}

And finally, the onMounted hook to initialize the canvas and the animate() function to start the animation:

onMounted(() => {
  if (canvasRef.value) {
    context.value = canvasRef.value.getContext('2d')
  }
  initCanvas()
  animate()
  window.addEventListener('resize', initCanvas)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', initCanvas)
})

The Vue component is also ready, and here is the complete code:

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, reactive, watch } from 'vue'
import useMousePosition from './utils/MousePosition'

const canvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLDivElement | null>(null)
const context = ref<CanvasRenderingContext2D | null>(null)
const circles = ref<any[]>([])
const mousePosition = useMousePosition()
const mouse = reactive<{ x: number; y: number }>({ x: 0, y: 0 })
const canvasSize = reactive<{ w: number; h: number }>({ w: 0, h: 0 })
const dpr = window.devicePixelRatio || 1

onMounted(() => {
  if (canvasRef.value) {
    context.value = canvasRef.value.getContext('2d')
  }
  initCanvas()
  animate()
  window.addEventListener('resize', initCanvas)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', initCanvas)
})

watch(
  () => mousePosition.value,
  () => {
    onMouseMove()
  }
)

const initCanvas = () => {
  resizeCanvas()
  drawParticles()
}

const onMouseMove = () => {
  if (canvasRef.value) {
    const rect = canvasRef.value.getBoundingClientRect()
    const { w, h } = canvasSize
    const x = mousePosition.value.x - rect.left - w / 2
    const y = mousePosition.value.y - rect.top - h / 2
    const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
    if (inside) {
      mouse.x = x
      mouse.y = y
    }
  }
}

const resizeCanvas = () => {
  if (canvasContainerRef.value && canvasRef.value && context.value) {
    circles.value.length = 0
    canvasSize.w = canvasContainerRef.value.offsetWidth
    canvasSize.h = canvasContainerRef.value.offsetHeight
    canvasRef.value.width = canvasSize.w * dpr
    canvasRef.value.height = canvasSize.h * dpr
    canvasRef.value.style.width = canvasSize.w + 'px'
    canvasRef.value.style.height = canvasSize.h + 'px'
    context.value.scale(dpr, dpr)
  }
}

type Circle = {
  x: number
  y: number
  translateX: number
  translateY: number
  size: number
  alpha: number
  targetAlpha: number
  dx: number
  dy: number
  magnetism: number
}

const circleParams = (): Circle => {
  const x = Math.floor(Math.random() * canvasSize.w)
  const y = Math.floor(Math.random() * canvasSize.h)
  const translateX = 0
  const translateY = 0
  const size = Math.floor(Math.random() * 2) + 1
  const alpha = 0
  const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
  const dx = (Math.random() - 0.5) * 0.2
  const dy = (Math.random() - 0.5) * 0.2
  const magnetism = 0.1 + Math.random() * 4
  return { x, y, translateX, translateY, size, alpha, targetAlpha, dx, dy, magnetism }
}

const drawCircle = (circle: Circle, update = false) => {
  if (context.value) {
    const { x, y, translateX, translateY, size, alpha } = circle
    context.value.translate(translateX, translateY)
    context.value.beginPath()
    context.value.arc(x, y, size, 0, 2 * Math.PI)
    context.value.fillStyle = `rgba(255, 255, 255, ${alpha})`
    context.value.fill()
    context.value.setTransform(dpr, 0, 0, dpr, 0, 0)

    if (!update) {
      circles.value.push(circle)
    }
  }
}

const clearContext = () => {
  if (context.value) {
    context.value.clearRect(0, 0, canvasSize.w, canvasSize.h)
  }
}

const drawParticles = () => {
  clearContext()
  const particleCount = props.quantity
  for (let i = 0; i < particleCount; i++) {
    const circle = circleParams()
    drawCircle(circle)
  }
}

const remapValue = (
  value: number,
  start1: number,
  end1: number,
  start2: number,
  end2: number
): number => {
  const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
  return remapped > 0 ? remapped : 0
}

const animate = () => {
  clearContext()
  circles.value.forEach((circle, i) => {
    // Handle the alpha value
    const edge = [
      circle.x + circle.translateX - circle.size, // distance from left edge
      canvasSize.w - circle.x - circle.translateX - circle.size, // distance from right edge
      circle.y + circle.translateY - circle.size, // distance from top edge
      canvasSize.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
    ]
    const closestEdge = edge.reduce((a, b) => Math.min(a, b))
    const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2))
    if (remapClosestEdge > 1) {
      circle.alpha += 0.02
      if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha
    } else {
      circle.alpha = circle.targetAlpha * remapClosestEdge
    }
    circle.x += circle.dx
    circle.y += circle.dy
    circle.translateX += ((mouse.x / (props.staticity / circle.magnetism)) - circle.translateX) / props.ease
    circle.translateY += ((mouse.y / (props.staticity / circle.magnetism)) - circle.translateY) / props.ease
    // circle gets out of the canvas
    if (
      circle.x < -circle.size ||
      circle.x > canvasSize.w + circle.size ||
      circle.y < -circle.size ||
      circle.y > canvasSize.h + circle.size
    ) {
      // remove the circle from the array
      circles.value.splice(i, 1)
      // create a new circle
      const newCircle = circleParams()
      drawCircle(newCircle)
      // update the circle position
    } else {
      drawCircle(
        {
          ...circle,
          x: circle.x,
          y: circle.y,
          translateX: circle.translateX,
          translateY: circle.translateY,
          alpha: circle.alpha,
        },
        true
      )
    }
  })
  window.requestAnimationFrame(animate)
}

interface Props {
  quantity?: number
  staticity?: number
  ease?: number
}

const props = withDefaults(defineProps<Props>(), {
  quantity: 30,
  staticity: 50,
  ease: 50,
})
</script>


<template>
  <div ref="canvasContainerRef" aria-hidden="true">
    <canvas ref="canvasRef"></canvas>
  </div>
</template>

You can import and use it as a regular Vue component. For example:

<Particles class="absolute inset-0 pointer-events-none" :quantity="50" />

We have reached the end of this tutorial. We hope you’ve found it helpful and have got a clear idea of how to apply this fantastic effect to your layouts to impress your visitors and add dynamism to your designs.

Remember that in our tutorial, we applied this effect to a hero section, but feel free to get creative and replicate it anywhere else in your apps, websites, and landing pages.

If you enjoyed the tutorial, don’t forget to check out the other Tailwind CSS tutorials on our website, and tag or mention us if you share our content on Twitter.