Aduok Code

A Spider Cursor, Built from a Wandering Follow Target, a Field of Anchor Dots, and Wobbly Sine-Noise Legs Drawn Frame by Frame on Canvas

Introduction

This spider cursor has no animation library, no sprites, and no SVG. There is a black canvas, a few hundred white dots scattered across it, and two small bodies that chase your pointer. When a body walks near a dot, legs reach out and attach to it; when it walks away, the legs retract. The result looks like a living web being pulled around by the cursor.

The effect is built from four small ideas: a follow target with a built-in wander, a field of anchor dots, legs drawn as noisy lines instead of straight ones, and a single requestAnimationFrame loop that repaints everything. Each idea is a short piece of code, and a Spider class ties them together.

What you will learn

How to size a canvas correctly for high-density screens — how to make a follower feel alive with sine-based wandering — how to pick which dots a spider may grab — how to draw a wobbly line from layered sine waves — and how to organise it all with classes and a config object.

01
Part One
A Sharp, Resizable Canvas

Step 1 — Match the Canvas to the Screen

A canvas has two sizes: the pixels it stores (its width and height attributes) and the space it takes on the page (its CSS size). If they differ, the browser stretches the bitmap and everything looks soft. The fix is to multiply the stored size by devicePixelRatio, then scale the drawing context by the same amount so the rest of the code can keep thinking in plain CSS pixels.

JavaScript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const viewport = { width: 0, height: 0 };

function resizeCanvas() {
  const dpr = window.devicePixelRatio || 1;
  viewport.width = window.innerWidth;
  viewport.height = window.innerHeight;
  canvas.width = viewport.width * dpr;
  canvas.height = viewport.height * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

The page itself only needs a few lines of CSS: the body has no margin and hidden overflow, and the canvas is a block element that fills the screen. Setting touch-action to none on the canvas lets a finger drive the spiders on a phone instead of scrolling the page.

02
Part Two
A Body That Follows Lazily

Step 2 — Chase a Target That Never Sits Still

If a spider moved straight to the pointer it would look like a second cursor. Two things make it feel alive. First, it only closes a fraction of the remaining distance each frame, so it eases in and trails behind quick movements. Second, the point it chases is not the pointer itself but the pointer plus a slow circular offset, so even a motionless cursor leaves the spider pacing around it.

JavaScript
#move(time) {
  const goalX = this.target.x + cos(time * this.wanderSpeed.x + this.seed) * this.wanderRadius.x;
  const goalY = this.target.y + sin(time * this.wanderSpeed.y + this.seed) * this.wanderRadius.y;
  const maxStep = viewport.width / 100;

  this.x += min(maxStep, (goalX - this.x) * CONFIG.followEase);
  this.y += min(maxStep, (goalY - this.y) * CONFIG.followEase);
}

Each spider gets its own random seed, wander speed, and wander radius when it is created, so two spiders never walk in sync. The min() call caps how far the body can travel in one frame. Without that cap, a pointer that jumps across the screen would make the spider teleport.

Why cos for x and sin for y

Using cosine on one axis and sine on the other traces a circle or an ellipse. Giving the two axes slightly different speeds turns that loop into a drifting, never-quite-repeating path, which reads as wandering rather than orbiting.

03
Part Three
Anchor Dots the Legs Can Grab

Step 3 — A Scatter of Points with a Reach Value

Each spider owns 333 anchors: random positions across the screen, each with two changing numbers. The radius controls how big the dot is drawn. The reach goes from 0 (leg fully retracted) to 1 (leg fully attached) and is what animates the legs growing and shrinking.

JavaScript
#updateAnchors() {
  const grabDistance = viewport.width / 10;
  let activeLegs = 0;

  for (const anchor of this.anchors) {
    const distance = hypot(anchor.x - this.x, anchor.y - this.y);
    const isGrabbed = distance < grabDistance && activeLegs++ < CONFIG.maxActiveLegs;

    anchor.radius = min(2, viewport.width / distance / 5) * (isGrabbed ? 1.5 : 1);
    anchor.reach = clamp(
      anchor.reach + (isGrabbed ? CONFIG.reachSpeed : -CONFIG.reachSpeed),
      0,
      1,
    );
  }
}

An anchor counts as grabbed only if it is inside the grab distance and fewer than eight others were already grabbed this frame. That limit keeps the spider looking like a spider instead of a ball of hair. Because reach moves by 0.1 per frame instead of jumping, a leg takes about ten frames to extend or retract, which gives the smooth reaching motion.

The dot radius is inversely proportional to distance, capped at 2 pixels. Dots close to a spider are larger and dots far away shrink to specks, which adds a soft sense of depth around each body without any extra drawing.

04
Part Four
Legs from Wobbly Lines

Step 4 — Nine Legs per Grabbed Dot

Every spider has nine leg origins placed evenly around a small circle, stored as unit vectors using cos and sin. For every anchor with a non-zero reach, a line is drawn from each origin toward that anchor. The end of the line nearest the anchor is pulled back by the reach value squared, so the tip eases out quickly at first and settles gently as it arrives.

JavaScript
#drawLegsTo(anchor) {
  if (anchor.reach === 0) return;
  const t = anchor.reach ** 2;

  for (const origin of this.legOrigins) {
    const startX = this.x + origin.x * this.bodyRadius;
    const startY = this.y + origin.y * this.bodyRadius;
    drawWobblyLine(lerp(startX, anchor.x, t), lerp(startY, anchor.y, t), startX, startY);
  }
}

A straight line would look like a laser. The legs look organic because drawWobblyLine breaks each line into 60 tiny segments and nudges every point by a small amount of noise. The noise is two layered sine waves: each wave is bent by another sine inside it, which produces smooth but irregular wiggles without needing a noise library.

JavaScript
function wobble(x, y, t = 101) {
  const a = sin(0.3 * x + 1.4 * t + 2.0 + 2.5 * sin(0.4 * y - 1.3 * t + 1.0));
  const b = sin(0.2 * y + 1.5 * t + 2.8 + 2.3 * sin(0.5 * x - 1.2 * t + 0.5));
  return a + b;
}

function drawWobblyLine(x0, y0, x1, y1) {
  ctx.beginPath();
  ctx.moveTo(x0, y0);
  for (let i = 1; i <= CONFIG.legSegments; i++) {
    const t = i / CONFIG.legSegments;
    const x = lerp(x0, x1, t);
    const y = lerp(y0, y1, t);
    const offset = wobble(x / 5 + x0, y / 5 + y0) * 2;
    ctx.lineTo(x + offset, y + offset);
  }
  ctx.stroke();
}

The time argument of wobble is a constant, so the legs do not shimmer on their own. The wiggle is baked into each leg by its position, which is why moving a spider makes the legs flex and settle like real joints.

05
Part Five
The Frame Loop and Pointer

Step 5 — Clear, Update, Draw, Repeat

Every frame starts by painting the whole canvas black, which erases the previous frame. Then the fill and stroke colours are set to white, and each spider updates its position and anchors before drawing. requestAnimationFrame passes a timestamp in milliseconds; dividing by 1000 gives the seconds value the wander maths expects.

JavaScript
function frame(timestamp) {
  ctx.fillStyle = CONFIG.colors.background;
  ctx.fillRect(0, 0, viewport.width, viewport.height);
  ctx.fillStyle = ctx.strokeStyle = CONFIG.colors.foreground;

  const seconds = timestamp / 1000;
  for (const spider of spiders) {
    spider.update(seconds);
    spider.draw();
  }
  requestAnimationFrame(frame);
}

window.addEventListener('pointermove', ({ clientX, clientY }) => {
  spiders.forEach((spider) => spider.follow(clientX, clientY));
});

The pointermove event covers mouse, pen, and touch with one listener. The handler does nothing but store the new coordinates on each spider. All real work stays in the frame loop, so a mouse that reports hundreds of events per second never causes extra drawing.

06
Part Six
Structure and Clean Code

Step 6 — Classes, Config, and No Globals

The whole script sits inside an immediately invoked function with strict mode on, so nothing leaks onto window. Every tunable number lives in one frozen CONFIG object. The Spider class owns its state and exposes only follow(), update(), and draw(); its helper methods are private with the # prefix, so the rest of the page cannot call them by accident.

JavaScript
const CONFIG = Object.freeze({
  spiderCount: 2,
  anchorsPerSpider: 333,
  legsPerSpider: 9,
  maxActiveLegs: 8,
  legSegments: 60,
  reachSpeed: 0.1,
  followEase: 0.1,
  colors: { background: '#000', foreground: '#fff' },
});

window.addEventListener('resize', init);
Resize and touch

A resize listener calls init(), which resizes the canvas and creates fresh spiders and anchors for the new dimensions. Touch-action none on the canvas stops the page from scrolling while a finger drags the spiders around.

Tuning Reference

PropertyExampleEffect
spiderCount2How many spiders chase the pointer
anchorsPerSpider333Density of the dot field each spider can grab
legsPerSpider9Legs drawn to every grabbed dot
maxActiveLegs8Maximum dots grabbed at once; higher looks denser
legSegments60Points per leg; fewer is rougher, more is smoother
reachSpeed0.1How quickly legs extend and retract per frame
followEase0.1Fraction of the distance closed each frame; lower feels lazier
wanderRadius50–100pxHow far a spider paces around the cursor
grabDistancewidth / 10How close a dot must be before a leg reaches for it

Full Source Code

The whole project is a single self-contained HTML file — markup, styles, and script, with no build step and no external requests.

HTML — spider-cursor.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/spider-cursor.html
Want to try it yourself?Open the live spider-cursor.html file