Live Demo / Download
—
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.
Quick navigation
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.
lang="en">
charset="utf-8">
Particle Animation
name="viewport" content="width=device-width,initial-scale=1">
class="font-inter antialiased">
class="relative min-h-screen flex flex-col justify-center bg-slate-900 overflow-hidden">
class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
class="absolute inset-0 pointer-events-none" aria-hidden="true">
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 thedata-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
andheight
of the canvas, as the dimensions will be inherited – via JS – from the parent container, which in our case is thediv
tag with the classabsolute 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:
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
, andease
) with default values - We have defined the same
const
variables that we used in the JavaScript code, withuseRef
- 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 wrappingelement, 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
mouse-position.tsx
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 ourParticles
component usingimport MousePosition from './utils/mouse-position'
, we can define a new constant variable that will hold the mouse coordinates. We can then use this variable as a dependency in thewatch
function that executes theonMouseMove
function.In simpler terms, the
onMouseMove
function will be executed every time the mouse moves.const mousePosition = MousePosition() 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 MousePosition from './utils/mouse-position' 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 = MousePosition() 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:ref="canvasContainerRef" aria-hidden="true">As in the React version, we have created a functional component that accepts the props
quantity
,staticity
, andease
. We have also defined the sameconst
variables used in the JavaScript code, but in this case, we have defined them asref
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 theanimate()
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:
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.