Dynamic Flow Fields with Vanilla JS and HTML Canvas
Have you ever been mesmerized by abstract particle animations? These flowing, dynamic visuals can be achieved with surprisingly simple techniques using plain JavaScript and the HTML canvas element. In this article, we will break down the process of creating a flow field that animates thousands of particles, giving them a natural movement.
1. Setting Up the Project
To start, we need three files: an HTML file to set up the canvas, a CSS file for styling, and a JavaScript file for handling the logic.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flow Fields</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<canvas id="canvas1"></canvas>
<script src="script.js"></script>
</body>
</html>
Explanation:
- We define a
<canvas>
element where all our animations will take place. - The
styles.css
will be linked to style the canvas. - The main animation logic is contained in
script.js
.
2. Styling the Canvas with CSS
Let’s add a simple style to give our canvas a black background and make sure all padding and margins are removed.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
canvas {
background-color: black;
}
Explanation:
- Setting
margin
andpadding
to zero ensures that the canvas fills the entire screen. - The black background provides a nice contrast for the white particles.
3. Setup the Canvas: using library benefit
/** @type{HTMLCanvasElement} **/
const canvas = document.getElementById('canvas1');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
ctx.fillStyle = 'white';
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;
Explanation:
-
/** @type{HTMLCanvasElement} **/
: This JSDoc comment specifies that the following variable is an HTMLCanvasElement. It helps with type checking and enhances code editor IntelliSense. -
canvas
: Selects the element from the DOM with the ID canvas1. -
ctx
: Obtains the 2D rendering context, which provides methods and properties for drawing on the canvas. - Sets the canvas width and height to match the current window's inner width and height, ensuring the canvas fills the viewport.
4. Particle Class: Creating the Magic
The Particle
class is where the core of the animation lies. Each particle moves across the canvas, leaving a trail of its past locations, creating the flowing effect.
class Particle {
constructor(effect) {
this.effect = effect;
this.x = Math.floor(Math.random() * this.effect.width);
this.y = Math.floor(Math.random() * this.effect.height);
this.speedModifier = Math.floor(Math.random() * 5 + 1);
this.history = [{ x: this.x, y: this.y }];
this.maxLength = Math.floor(Math.random() * 200 + 10);
this.timer = this.maxLength * 2;
this.colors = ['#4C026B', '#8E0E00', '#9D0208', '#BA1A1A', '#730D9E'];
this.color = this.colors[Math.floor(Math.random() * this.colors.length)];
}
draw(context) {
context.beginPath();
context.moveTo(this.history[0].x, this.history[0].y);
for (let i = 1; i < this.history.length; i++) {
context.lineTo(this.history[i].x, this.history[i].y);
}
context.strokeStyle = this.color;
context.stroke();
}
update() {
this.timer--;
if (this.timer >= 1) {
let x = Math.floor(this.x / this.effect.cellSize);
let y = Math.floor(this.y / this.effect.cellSize);
let index = y * this.effect.cols + x;
let angle = this.effect.flowField[index];
this.speedX = Math.cos(angle);
this.speedY = Math.sin(angle);
this.x += this.speedX * this.speedModifier;
this.y += this.speedY * this.speedModifier;
this.history.push({ x: this.x, y: this.y });
if (this.history.length > this.maxLength) {
this.history.shift();
}
} else if (this.history.length > 1) {
this.history.shift();
} else {
this.reset();
}
}
reset() {
this.x = Math.floor(Math.random() * this.effect.width);
this.y = Math.floor(Math.random() * this.effect.height);
this.history = [{ x: this.x, y: this.y }];
this.timer = this.maxLength * 2;
}
}
Explanation:
-
Constructor: Each particle is initialized with a random position and movement speed. The
history
array tracks past positions to create trails. - draw(): This function draws the particle’s path based on its history. The particle leaves a colorful trail that adds to the visual effect.
- update(): Here, the particle's position is updated by calculating the angle from the flow field. The speed and direction are controlled by trigonometric functions.
- reset(): When the particle finishes its trail, it is reset to a new random location.
5. Effect Class: Organizing the Animation
The Effect
class handles the creation of particles and the flow field itself, which controls the movement of the particles.
class Effect {
constructor(canvas) {
this.canvas = canvas;
this.width = this.canvas.width;
this.height = this.canvas.height;
this.particles = [];
this.numberOfParticles = 3000;
this.cellSize = 20;
this.flowField = [];
this.curve = 5;
this.zoom = 0.12;
this.debug = true;
this.init();
}
init() {
this.rows = Math.floor(this.height / this.cellSize);
this.cols = Math.floor(this.width / this.cellSize);
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
let angle = (Math.cos(x * this.zoom) + Math.sin(y * this.zoom)) * this.curve;
this.flowField.push(angle);
}
}
for (let i = 0; i < this.numberOfParticles; i++) {
this.particles.push(new Particle(this));
}
}
drawGrid(context) {
context.save();
context.strokeStyle = 'white';
context.lineWidth = 0.3;
for (let c = 0; c < this.cols; c++) {
context.beginPath();
context.moveTo(c * this.cellSize, 0);
context.lineTo(c * this.cellSize, this.height);
context.stroke();
}
for (let r = 0; r < this.rows; r++) {
context.beginPath();
context.moveTo(0, r * this.cellSize);
context.lineTo(this.width, r * this.cellSize);
context.stroke();
}
context.restore();
}
render(context) {
if (this.debug) this.drawGrid(context);
this.particles.forEach(particle => {
particle.draw(context);
particle.update();
});
}
}
Explanation:
- Constructor: Initializes the canvas dimensions, the number of particles, and the flow field.
- init(): Calculates the angles for the flow field by combining trigonometric functions for each grid cell. This field influences how particles move.
- drawGrid(): Draws the grid that divides the canvas into cells, used when debugging.
-
render(): Calls the
draw
andupdate
methods for each particle to animate the particles across the canvas.
6. Bringing it to Life with the Animation Loop
To make everything work, we need an animation loop that continuously clears the canvas and re-renders the particles:
const effect = new Effect(canvas);
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
effect.render(ctx);
requestAnimationFrame(animate);
}
animate();
Explanation:
- clearRect(): Clears the canvas on each frame to avoid drawing over previous frames.
-
requestAnimationFrame: Keeps the animation smooth by recursively calling the
animate()
function.
Conclusion
By breaking down the Particle
and Effect
classes, we have created a fluid and dynamic flow field animation using only vanilla JavaScript. The simplicity of the HTML canvas, combined with JavaScript's trigonometric functions, allows us to build these mesmerizing visual effects.
Feel free to play around with the particle count, colors, or the flow field formula to create your own unique effects!
Top comments (0)