Floating Particles Effect with P5.js

javascriptp5reactsimulationweb

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>
  );
};

Conclusion

Thanks for reading! In this post I reviewed how I implemented the particles effect found one my site's homepage. If you like the capability that P5 provides do checkout out its examples page linked above. In particular I found it to be an neat way to simulation physics or maths concepts within the browser.