Floating Particles Effect with P5.js
In this post I discuss how I implemented the floating particles effect that can be found in the hero section of my homepage. Such an effect provides a bit of dynamic flair to sites while not becoming the focal point of the page.
P5.js - art with javascript
Let's begin with some background on the packages I use to implement the effect. P5.js is an open-source javascript library for drawing basics like circles, lines, or points. These basics can build complex figures like 3D shapes, effects, and simulations - checkout some examples here.
To get started, the p5 editor can be found here. Since my website is implemented in NextJs, I've use the p5-wrapper/next library for integrating the Javascript library in Let's start by drawing a simple circle:
"use client";
import { NextReactP5Wrapper } from "@p5-wrapper/next";
import { Sketch, P5CanvasInstance } from "@p5-wrapper/react";
const P5ParticlesPartOne = () => {
const sketch: Sketch = (p5: P5CanvasInstance) => {
const canvasWrapper = document.getElementById('canvas-wrapper')!;
p5.setup = () => {
p5.createCanvas(canvasWrapper.clientWidth, canvasWrapper.clientHeight);
}
p5.draw = () => {
p5.circle(p5.width/2, p5.height/2, 25);
};
};
return (
<div id="canvas-wrapper" className="w-full h-full">
<NextReactP5Wrapper sketch={sketch} />
</div>
)
};
We define a sketch
object that contains all the details for rendering which is then passed to the
NextReactP5Wrapper
that itself is contained within a div wrapper.
This is done as I make use of Tailwind's w-full
and h-full
classes.
P5 has two required functions setup
and draw
for initialization of the P5 canvas and how to render each frame.
The draw
function is where we will implement the particle effect.
Moving particles
Let's describe the task before diving in:
- We'll have a collection of particles, each moving with some velocity and direction.
- Whenever two particles are near each other we'll draw a line connecting them.
- Whenever a particle reaches the boundary of our canvas we'll reflect it back in.
It makes sense to wrap most of this logic within a Particle
class with functions like so:
class Particle {
pos: Array<number>;
vel: Array<number>;
radius: number;
// Set position and velocity of node using random values.
constructor() {
this.pos = [p5.random(0, p5.width), p5.random(0, p5.height)];
this.vel = [p5.random(-1,1), p5.random(-1,1)];
this.radius = p5.random(2,5);
}
// Draw particle
drawParticle() {
p5.noStroke();
p5.fill("rgba(41, 38, 28, 0.6)");
p5.circle(this.pos[0], this.pos[1], this.radius);
}
// Update position of position
moveParticle() {
// Reflect particles if they reach the boundary of the screen
if (this.pos[0] < 0 || this.pos[0] > p5.width) {
this.vel[0] *= -1;
}
if (this.pos[1] < 0 || this.pos[1] > p5.height) {
this.vel[1] *= -1;
}
this.pos[0] += this.vel[0];
this.pos[1] += this.vel[1];
}
// Draw a line between particles that are close enough
connectParticles(particles: Array<Particle>) {
particles.forEach(particle => {
const distance = p5.dist(this.pos[0], this.pos[1], particle.pos[0], particle.pos[1]);
if (distance < 35) {
p5.stroke("rgba(10, 10, 10, 0.05)");
p5.line(this.pos[0], this.pos[1], particle.pos[0], particle.pos[1])
}
});
};
};
We initialize the particle with some random values within our canvas.
The drawParticle
function first removes any strokes attached to the particle then draws the particle as a circle.
The moveParticle
function update's the particle's position, ensuring it remains within the canvas' wrapper.
The connectParticles
function looks at every pairing of particles and draws a line connecting them if the distance between is small enough.
We create an array of particles and then update the setup
and draw
functions using the particles class.
// Particle class...
const particles:Array<Particle> = [];
const numberOfParticles = 20;
p5.setup = () => {
p5.createCanvas(canvasWrapper.clientWidth, canvasWrapper.clientHeight);
// create particles
for(let i=0; i<numberOfParticles; i++) {
particles.push(new Particle());
};
}
p5.draw = () => {
// Overwrite previous frame's particles by painting over it with background color.
p5.background("#FCFCFD");
// Draw each particle.
for(let i=0; i<particles.length; i++) {
particles[i].drawParticle();
particles[i].moveParticle();
// slice out the current particle to avoid self-connecting lines.
particles[i].connectParticles(particles.slice(i));
};
};
At this point you'll have the basics of the particle effect however there are some enhancements that can really sharpen the effect. The first is to connect into the page's theme manager and apply different colors depending on the light and dark theme. The second is to make the effect dynamic with variable screen widths.
For the former I use utility functions that fetch the appropriate color given the current theme.
For updating the effect in response to changing screen width we can use P5's windowResized
function to resize the canvas:
const responsedToResize = () => {
canvasWidth = canvasWrapper.clientWidth;
canvasHeight = canvasWrapper.clientHeight;
// Scale number of particles dependent on screen width.
numOfParticles = canvasWidth / 20;
p5.resizeCanvas(canvasWidth, canvasHeight);
}
p5.windowResized = () => responsedToResize();
The final code can be found below:
const Particles = () => {
const { systemTheme, theme } = useTheme();
const currentTheme = theme === "system" ? systemTheme : theme;
const lightBg = "#FCFCFD";
const darkBg = "#272D2D";
const lightParticle = "rgba(41, 38, 28, 0.6)";
const darkParticle = "rgba(185, 169, 169, 0.4)";
const lightEdge = "rgba(10, 10, 10, 0.05)";
const darkEdge = "rgba(255, 255, 255, 0.03)";
const getBackgroundColor = () => currentTheme === "light" ? lightBg : darkBg;
const getParticleColor = () => currentTheme === "light" ? lightParticle : darkParticle;
const getEdgeColor = () => currentTheme === "light" ? lightEdge : darkEdge;
const particlesSketch: Sketch = (p5: P5CanvasInstance) => {
// placeholder dimensions
let canvasWidth = 400;
let canvasHeight = 400;
let numOfParticles = 20;
const particles: Array<Particle> = [];
const canvasWrapper = document.getElementById('canvas-wrapper-3')!;
const responsedToResize = () => {
canvasWidth = canvasWrapper.clientWidth;
canvasHeight = canvasWrapper.clientHeight;
numOfParticles = canvasWidth / 20;
p5.resizeCanvas(canvasWidth, canvasHeight);
}
p5.setup = () => {
canvasWidth = canvasWrapper.clientWidth;
canvasHeight = canvasWrapper.clientHeight;
numOfParticles = canvasWidth / 20;
p5.createCanvas(canvasWidth, canvasHeight);
for (let i=0; i< numOfParticles; i++) {
particles.push(new Particle());
}
};
p5.windowResized = () => responsedToResize();
p5.draw = () => {
p5.background(getBackgroundColor());
for (let i=0; i<particles.length; i++) {
particles[i].drawParticle();
particles[i].moveParticle();
particles[i].connectParticles(particles.slice(i));
}
}
class Particle {
// 2D array <xPos,yPos>
pos: Array<number>;
// <xVel, yVel>
vel: Array<number>;
radius: number;
constructor() {
this.pos = [p5.random(0, p5.width), p5.random(0, p5.height)];
this.vel = [p5.random(-1,1), p5.random(-1,1)];
this.radius = p5.random(2,5);
}
drawParticle() {
p5.noStroke();
p5.fill(getParticleColor());
p5.circle(this.pos[0], this.pos[1], this.radius);
}
moveParticle() {
// Reflect particles if they reach the boundary of the screen
if (this.pos[0] < 0 || this.pos[0] > p5.width) {
this.vel[0] *= -1;
}
if (this.pos[1] < 0 || this.pos[1] > p5.height) {
this.vel[1] *= -1;
}
this.pos[0] += this.vel[0];
this.pos[1] += this.vel[1];
}
// draw a line between particles that are close enough
connectParticles(particles: Array<Particle>) {
particles.forEach(particle => {
const distance = p5.dist(this.pos[0], this.pos[1], particle.pos[0], particle.pos[1]);
if (distance < 35) {
p5.stroke(getEdgeColor());
p5.line(this.pos[0], this.pos[1], particle.pos[0], particle.pos[1])
}
});
}
}
};
return (
<div id='canvas-wrapper-3' className='w-full h-full'>
<NextReactP5Wrapper sketch={particlesSketch} />
</div>
);
};
Thanks for reading!