Hey, that’s me again – George Toroza! I am the co-founder and creative director of DDS Studios. Today I have a fun idea to mess with the P5 brush and share it that came to me while I was sketching in a notebook.
Create a cool stop motion crayon cursor effect using p5.brush.js. This is a neat collection of P5.JS features that allow you to draw canvas.
Let’s get started!
HTML Markup
<div id="canvas-container"></div>
It’s pretty simple isn’t it? Only P5 canvas containers are required.
CSS Style
#canvas-container
width: 100%;
height: 100%;
The same can be said about CSS. Just set the size.
Canvas Manager
This is the structure of the Canvas class. Here we handle all the calculations and requestAnimationFrame
(RAF) call. Planning is simple. Draw a fluid polygon and create a list of trails following the cursor.
import * as brush from 'p5.brush';
import p5 from 'p5';
export default class CanvasManager {
constructor()
this.width = window.innerWidth;
this.height = window.innerHeight;
this.trails = [];
this.activeTrail = null;
this.mouse =
x: c: -100, t: -100 ,
y: c: -100, t: -100 ,
delta: c: 0, t: 0 ,
;
this.polygonHover = c: 0, t: 0 ;
this.maxTrailLength = 500;
this.t = 0;
this.el = document.getElementById('canvas-container');
this.render = this.render.bind(this);
this.sketch = this.sketch.bind(this);
this.initBrush = this.initBrush.bind(this);
this.resize = this.resize.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
window.addEventListener('resize', this.resize);
document.addEventListener('mousedown', this.mousedown);
document.addEventListener('mousemove', this.mousemove);
document.addEventListener('mouseup', this.mouseup);
this.resize();
this.initCanvas();
resize()
this.width = window.innerWidth;
this.height = window.innerHeight;
this.polygon = this.initPolygon();
if (this.app) this.app.resizeCanvas(this.width, this.height, true);
initCanvas()
this.app = new p5(this.sketch, this.el);
requestAnimationFrame(this.render);
...
The constructor is pretty standard. Set up all the properties and add objects for linear interpolation. Here I’m using c
For the present t
For targets.
Let’s start with polygons. I quickly sketched polygons in Figma, copied vertices, and paid attention to the size of the Figma canvas.

Now there is this set of points. The plan is to create two states of the polygon. It is a rest state and a hover state with different vertex positions. Next, we process each point and normalize the coordinates by dividing the coordinates by the grid size or figma canvas size, making sure they are in the range of 0-1. Then, multiply these normalized values by the width and height of the canvas and then use our viewport. Finally, set the current and target states and return the points.
initPolygon()
const gridSize = x: 1440, y: 930 ;
const basePolygon = [
x: c: 0, t: 0, rest: 494, hover: 550 , y: c: 0, t: 0, rest: 207, hover: 310 ,
x: c: 0, t: 0, rest: 1019, hover: 860 , y: c: 0, t: 0, rest: 137, hover: 290 ,
x: c: 0, t: 0, rest: 1035, hover: 820 , y: c: 0, t: 0, rest: 504, hover: 520 ,
x: c: 0, t: 0, rest: 377, hover: 620 , y: c: 0, t: 0, rest: 531, hover: 560 ,
];
basePolygon.forEach((p) =>
p.x.rest /= gridSize.x;
p.y.rest /= gridSize.y;
p.x.hover /= gridSize.x;
p.y.hover /= gridSize.y;
p.x.rest *= this.width;
p.y.rest *= this.height;
p.x.hover *= this.width;
p.y.hover *= this.height;
p.x.t = p.x.c = p.x.rest;
p.y.t = p.y.c = p.y.rest;
);
return basePolygon;
Mouse function
Next, there is the mouse function. You need to listen to the next event. mousedown
, mousemove
and mouseup
. The user draws only when the mouse is pressed.
The logic is: Adds a new trail to the list and allows the user to retain the shape when the mouse is pressed down. When the mouse moves, check whether the current mouse position is within the polygon. There are many ways to optimize performance, but keep it simple for this search, such as using a bounding box on the polygon and performing calculations only when the mouse is inside the box. Instead, perform this check using a small function.
Maps the current value for each point and passes it to the function along with the mouse position. Based on isHover
Set the variable, then the target value for each vertex. I’ll update again polygonHover
Target and mouse target coordinates are used to animate trails and mouse circles on the canvas.
mousedown(e)
if (this.mouseupTO) clearTimeout(this.mouseupTO);
const newTrail = [];
this.trails.push(newTrail);
this.activeTrail = newTrail;
mousemove(e)
const isHover = this.inPolygon(e.clientX, e.clientY, this.polygon.map((p) => [p.x.c, p.y.c]));
this.polygon.forEach((p) =>
if (isHover)
p.x.t = p.x.hover;
p.y.t = p.y.hover;
else
p.x.t = p.x.rest;
p.y.t = p.y.rest;
);
this.polygonHover.t = isHover ? 1 : 0;
this.mouse.x.t = e.clientX;
this.mouse.y.t = e.clientY;
mouseup()
if (this.mouseupTO) clearTimeout(this.mouseupTO);
this.mouseupTO = setTimeout(() =>
this.activeTrail = null;
, 300);
inPolygon(x, y, polygon)
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++)
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
return inside;
Finally, you can set it activeTrail
In null
but add a small delay to show you some inertia.

OK, loop time
This class has two main loops render
Functions and draw
Functions from P5. Let’s start with render
function.
render
Features are one of the most important parts of the class. Here we handle all linear interpolation and update the trail.
render(time)
this.t = time * 0.001;
this.mouse.x.c += (this.mouse.x.t - this.mouse.x.c) * 0.08;
this.mouse.y.c += (this.mouse.y.t - this.mouse.y.c) * 0.08;
this.mouse.delta.t = Math.sqrt(Math.pow(this.mouse.x.t - this.mouse.x.c, 2) + Math.pow(this.mouse.y.t - this.mouse.y.c, 2));
this.mouse.delta.c += (this.mouse.delta.t - this.mouse.delta.c) * 0.08;
this.polygonHover.c += (this.polygonHover.t - this.polygonHover.c) * 0.08;
if (this.activeTrail)
this.activeTrail.push( x: this.mouse.x.c, y: this.mouse.y.c );
if (this.activeTrail.length > this.maxTrailLength) this.activeTrail.shift();
this.trails.forEach((trail) =>
if(this.activeTrail === trail) return;
trail.shift();
);
this.trails = this.trails.filter((trail) => trail && trail.length > 0);
this.polygon.forEach((p, i) =>
p.x.c += (p.x.t - p.x.c) * (0.07 - i * 0.01);
p.y.c += (p.y.t - p.y.c) * (0.07 - i * 0.01);
);
requestAnimationFrame(this.render);
Let’s dive deeper. First, there is a time
Variables are used to give polygons organic and dynamic movement. It then updates the current value using linear interpolation (lerps
). For mouse delta/velocity values, use the classic formula to find the distance between two points.
Well, for the trail, here’s the logic. If there is an active trail, start pushing the current position of the mouse. If the active trail exceeds the maximum length, start deleting old points. For inactive trails, remove points over time and remove “Dead trails without remaining points from the list.
Finally, use a to update the polygon lerp
adds a small delay between each point based on the index. This creates a smoother, more natural hovering behavior.
P5 Logic
We’re almost there! Once you have all the data you need, you can start drawing.
in initBrush
Set the size of the function, canvas and remove the fields. This time, there is no need to distortion in the curve. Next, configure the brush. There are many options to choose from, but be careful about performance when selecting a particular feature. Finally, scale the brush based on the window size to ensure everything is adjusted properly.
initCanvas()
this.app = new p5(this.sketch, this.el);
requestAnimationFrame(this.render);
initBrush(p)
brush.instance(p);
p.setup = () =>
p.createCanvas(this.width, this.height, p.WEBGL);
p.angleMode(p.DEGREES);
brush.noField();
brush.set('2B');
brush.scaleBrushes(window.innerWidth <= 1024 ? 2.5 : 0.9);
;
sketch(p)
this.initBrush(p);
p.draw = () =>
p.frameRate(30);
p.translate(-this.width / 2, -this.height / 2);
p.background('#FC0E49');
brush.stroke('#7A200C');
brush.strokeWeight(1);
brush.noFill();
brush.setHatch("HB", "#7A200C", 1);
brush.hatch(15, 45);
const time = this.t * 0.01;
brush.polygon(
this.polygon.map((p, i) => [
p.x.c + Math.sin(time * (80 + i * 2)) * (30 + i * 5),
p.y.c + Math.cos(time * (80 + i * 2)) * (20 + i * 5),
])
);
brush.strokeWeight(1 + 0.005 * this.mouse.delta.c);
this.trails.forEach((trail) =>
if (trail.length > 0)
brush.spline(trail.map(
);
brush.noFill();
brush.stroke('#FF7EBE');
brush.setHatch("HB", "#FFAABF", 1);
brush.hatch(5, 30, rand: 0.1, continuous: true, gradient: 0.3 )
const r = 5 + 0.05 * this.mouse.delta.c + this.polygonHover.c * (100 + this.mouse.delta.c * 0.5);
brush.circle(this.mouse.x.c, this.mouse.y.c, r);
;
lastly, sketch
A function that includes a drawing loop and implements all logic from previous calculations.
First, set up your FPS and select the tool you want to use for drawing. Start with polygons. Set the stroke color and weight and fill in the shape using a hatch pattern. You can explore the complete configuration of the tool in the documentation, but it’s easy to set up. Brush: HB, color: #7A200C
and Weight: 1. Then configure the hatch function and set the distance and angle. The last parameter is optional. A configuration object with additional options.
Once the brush is ready, you can draw the polygons. Using polygon
Function sends an array of points to P5. P5 is painted on canvas. Map current point coordinates and add using smooth movement Math.sin
and Math.cos
there are variations based on index and time
Variables for a more organic feel.
For trails, adjust strokeWeight
Based on mouse delta. For each trail, use spline
Functions are passed in a list of points and curvature. Next, for the mouse circle, remove the fill, set the stroke, and apply the hatch. The radius of a circle is dynamic. Scales based on mouse delta and adds a sense of responsiveness. Furthermore, when the mouse is inside the polygon, the radius increases, creating an immersive animation effect.
The result should look like this:
That’s it for today! Thank you for reading. To see more exploration and experimentation, follow me on Instagram.