Building a Playful Stop-Motion Crayon Cursor in p5.js


Hey, it’s me again—Jorge Toloza! I’m the Co-Founder and Creative Director at DDS Studio. Today, I’ve got a fun idea to share that came to me while I was messing around with p5 Brush and sketching in my notebook.

We’re going to create a cool stop-motion crayon cursor effect using p5.brush.js—a neat collection of functions for p5.js that lets you draw on a canvas—plus a little bit of math magic.

Let’s get started!

The HTML Markup

<div id="canvas-container"></div>

Pretty simple, right? We only need a container for the p5 canvas.

CSS Styles

#canvas-container {
    width: 100%;
    height: 100%;
}

The same goes for the CSS—just set the size.et the size.

Our Canvas Manager

Here’s the structure for our canvas class, where we’ll handle all the calculations and requestAnimationFrame (RAF) calls. The plan is straightforward: we’ll draw a fluid polygon and create a list of trails that follow 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 fairly standard—we’re setting up all the properties and adding some objects for linear interpolations. Here, I’m using c for current and t for target.

Let’s start with the polygon. I quickly sketched a polygon in Figma, copied the vertices, and noted the size of the Figma canvas.

We now have this array of points. The plan is to create two states for the polygon: a rest state and a hover state, with different vertex positions for each. We then process each point, normalizing the coordinates by dividing them by the grid size or Figma canvas size, ensuring they range from 0 to 1. After that, we multiply these normalized values by the canvas width and height to make all the coordinates relative to our viewport. Finally, we set the current and target states and return our 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;
}

The mouse functions

Next, we have the mouse functions. We need to listen for the following events: mousedown, mousemove, and mouseup. The user will only draw when the mouse is pressed down.

Here’s the logic: when the user presses the mouse down, we add a new trail to the list, allowing us to preserve the shapes. As the mouse moves, we check whether the current mouse position is inside the polygon. While there are many ways to optimize performance—like using a bounding box for the polygon and performing calculations only if the mouse is inside the box—we’ll keep it simple for this exploration. Instead, we’ll use a small function to perform this check.

We map the current values for each point and pass them to the function along with the mouse position. Based on the isHover variable, we then set the target values for each vertex. We’ll also update the polygonHover target and the mouse target coordinates, which we’ll use to animate the trails and the mouse circle 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;
}

Lastly, we can set the activeTrail to null, but we’ll add a small delay to introduce some inertia.

Ok, time for the loops

This class has two main loops: the render function and the draw function from p5. Let’s start with the render function.

The render function is one of the most important parts of the class. Here, we’ll handle all our linear interpolations and update the trails.

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, we have a time variable, which we’ll use to give the polygon an organic, dynamic movement. After that, we update the current values using linear interpolations (lerps). For the mouse’s delta/velocity value, we’ll use the classic formula for finding the distance between two points.

Now, for the trails, here’s the logic: if there’s an active trail, we start pushing the mouse’s current positions into it. If the active trail exceeds the maximum length, we begin removing older points. For the inactive trails, we also remove points over time and remove any “dead” trails—those with no remaining points—from the list.

Finally, we update the polygon using a lerp, adding a small delay between each point based on its index. This creates a smoother and more natural hover behavior.

p5 logic

We’re almost there! With all the necessary data in place, we can start drawing.

In the initBrush function, we set the sizes for the canvas and remove the fields, as we don’t want any distortion in our curves this time. Next, we configure the brush. There are plenty of options to choose from, but be mindful of performance when selecting certain features. Lastly, we scale the brush based on the window size to ensure everything adjusts 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((t) => [t.x, t.y]), 1);
      }
    });

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

Finally, we have the sketch function, which contains the drawing loop and implements all the logic from our previous calculations.

First, we set the FPS and choose the tools we’ll use for drawing. We begin with the polygon: setting the stroke color and weight, and removing the fill since we’ll use a hatch pattern to fill the shape. You can explore the full configurations for tools in their documentation, but our settings are simple: brush: HB, color: #7A200C, and weight: 1. After that, we configure the hatch function, setting the distance and angle. The last parameter is optional—a configuration object with additional options.

With our brush ready, we can now draw the polygon. Using the polygon function, we send an array of points to p5, which paints them onto the canvas. We map our current point coordinates and add smooth movement using Math.sin and Math.cos, with variations based on the index and the time variable for a more organic feel.

For the trails, we adjust the strokeWeight based on the mouse delta. For each trail, we use the spline function, passing a list of points and the curvature. Then, for the mouse circle, we remove the fill, set the stroke, and apply the hatch. The circle’s radius is dynamic: it scales based on the mouse delta, adding a sense of responsiveness. Additionally, the radius increases when the mouse is inside the polygon, creating an immersive animation effect.

The result should look something like this:

That’s it for today! Thanks for reading. To see more explorations and experiments, feel free to follow me on Instagram.



Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here