Copied!
Full page

Jiggle cursor dot

A see-through dot rides on the pointer and wobbles like jelly when you move fast, then calms back to a clean circle.

← Library

Element · Vanilla JS + SVG filter + CSSUpdated November 6, 2026

HeroElementVanilla JS + SVG filter + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Jiggle cursor dot" for my site using the Enrichr Library element at https://enrichr.dev/elements/jiggle-cursor-dot.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/jiggle-cursor-dot.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- color: #e6ff55
- opacity: 0.6
- size: 20
- jiggle: 20
- spin: 0.3
- blend: lighten
- idle: on
- background: #0c1e18

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/jiggle-cursor-dot.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-jcd" role="img" aria-label="A lime dot that follows the pointer and wobbles like jelly when it moves fast">
  <div class="ef-jcd__dot" aria-hidden="true"></div>
  <svg class="ef-jcd__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs><filter><feTurbulence type="fractalNoise" baseFrequency="0.015" numOctaves="1" result="noise"></feTurbulence><feDisplacementMap in="SourceGraphic" in2="noise" scale="0" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap></filter></defs></svg>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-jcd { position: relative; width: 100%; height: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-jcd .ef-jcd__dot { position: absolute; top: 0; left: 0; width: 20px; height: 20px; border-radius: 50%; background: #e6ff55; opacity: 0.6; mix-blend-mode: lighten; box-shadow: 0 0 10px #e6ff5588; pointer-events: none; will-change: transform; transform: translate(-50%, -50%); }
.ef-jcd .ef-jcd__defs { position: absolute; width: 0; height: 0; overflow: hidden; pointer-events: none; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Jiggle cursor dot: a soft, see-through dot sits exactly on the pointer and spins slowly (0.3 degrees a frame). It wears
// an SVG filter: low-frequency fractal noise (feTurbulence) drives a displacement map, so when the scale is above zero the
// dot's edge warps like jelly. The scale is the distance the pointer moved since the last frame times the jiggle amount,
// capped at 600, eased in at 0.05 per frame, so a quick flick makes the dot wobble and break up, then it calms back to a
// clean circle. With idle on, a virtual pointer darts between random spots while no one is pointing. Reduced motion keeps
// the dot on the pointer but drops the wobble, the spin and the idle darts. The loop pauses off screen and in hidden tabs.
// mount(root, { color, opacity, size (px), jiggle (wobble per pixel moved), spin (degrees per frame),
//               blend (CSS blend mode), idle (on | off), background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-jcd");
  var dot = root.querySelector(".ef-jcd__dot");
  var filter = root.querySelector(".ef-jcd__defs filter");
  var disp = root.querySelector(".ef-jcd__defs feDisplacementMap");
  var uid = "ef-jcd-" + Math.random().toString(36).slice(2, 8);
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
  var W = 1, H = 1, mouseX = 0, mouseY = 0, lastX = 0, lastY = 0, moved = false;
  var currentScale = 0, targetScale = 0, rotationAngle = 0, shownScale = -1;
  var inside = false, lastReal = -1e9, dart = null, rest = 0.8;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  filter.id = uid;
  filter.setAttribute("x", "-10%"); filter.setAttribute("y", "-10%"); filter.setAttribute("width", "120%"); filter.setAttribute("height", "120%");

  function place() {
    dot.style.transform = "translate(calc(" + mouseX + "px - 50%), calc(" + mouseY + "px - 50%)) rotate(" + rotationAngle + "deg)";
  }
  function setScale(s) {
    // skip the attribute write (and the filter repaint) when nothing visible changed
    if (Math.abs(s - shownScale) < 0.01) return;
    shownScale = s; disp.setAttribute("scale", s);
  }

  // idle demo: dart to a random spot on a sine in-out curve, rest, repeat
  function idle(now, dt) {
    if (opts.idle !== "on" || inside || now - lastReal < 1500) { dart = null; return; }
    if (!dart) {
      rest -= dt;
      if (rest > 0) return;
      dart = { t: 0, d: 0.5 + Math.random() * 0.45, x0: mouseX, y0: mouseY, x1: W * (0.15 + Math.random() * 0.7), y1: H * (0.15 + Math.random() * 0.7) };
    }
    dart.t += dt / dart.d;
    var p = Math.min(1, dart.t), e = (1 - Math.cos(Math.PI * p)) / 2;
    mouseX = dart.x0 + (dart.x1 - dart.x0) * e; mouseY = dart.y0 + (dart.y1 - dart.y0) * e;
    if (p >= 1) { dart = null; rest = 0.4 + Math.random() * 0.9; }
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    idle(now, dt);
    rotationAngle += opts.spin;
    place();
    var dx = mouseX - lastX, dy = mouseY - lastY;
    var velocity = Math.sqrt(dx * dx + dy * dy);
    targetScale = Math.min(velocity * opts.jiggle, 600);
    currentScale += (targetScale - currentScale) * 0.05;
    setScale(currentScale);
    lastX = mouseX; lastY = mouseY;
  }

  function onMove(e) {
    var r = wrap.getBoundingClientRect();
    inside = true; lastReal = performance.now(); moved = true; dart = null;
    mouseX = e.clientX - r.left; mouseY = e.clientY - r.top;
    if (reduce) place();
  }
  function onEnter() { inside = true; lastReal = performance.now(); }
  function onLeave() { inside = false; lastReal = performance.now(); }

  function size() {
    W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
    if (!moved) { mouseX = lastX = W / 2; mouseY = lastY = H / 2; } // the original starts in the middle of the page
    else { mouseX = Math.min(mouseX, W); mouseY = Math.min(mouseY, H); }
    place();
  }
  function update(partial) {
    opts = Object.assign({ color: "#e6ff55", opacity: 0.6, size: 20, jiggle: 20, spin: 0.3, blend: "lighten", idle: "on", background: "#0c1e18" }, opts, partial || {});
    opts.opacity = Math.min(1, Math.max(0, num(opts.opacity, 0.6))); opts.size = Math.max(1, num(opts.size, 20));
    opts.jiggle = Math.max(0, num(opts.jiggle, 20)); opts.spin = num(opts.spin, 0.3);
    var c = /^#[0-9a-f]{6}$/i.test(opts.color) ? opts.color : "#e6ff55";
    dot.style.background = c;
    dot.style.boxShadow = "0 0 10px " + c + "88";
    dot.style.opacity = opts.opacity;
    dot.style.width = dot.style.height = opts.size + "px";
    dot.style.mixBlendMode = opts.blend;
    dot.style.filter = reduce ? "none" : "url(#" + uid + ")";
    wrap.style.background = opts.background;
    if (!destroyed) place();
  }

  update(o);
  size();
  wrap.addEventListener("pointermove", onMove, { passive: true });
  wrap.addEventListener("pointerdown", onMove, { passive: true });
  wrap.addEventListener("pointerenter", onEnter);
  wrap.addEventListener("pointerleave", onLeave);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); lastReal = last - 700; // a short pause before the first idle dart
    raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(wrap); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerdown", onMove);
      wrap.removeEventListener("pointerenter", onEnter);
      wrap.removeEventListener("pointerleave", onLeave);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"color":"#e6ff55","opacity":0.6,"size":20,"jiggle":20,"spin":0.3,"blend":"lighten","idle":"on","background":"#0c1e18"});

How it works

The dot sits exactly on the pointer, with no lag, and turns 0.3 degrees every frame. It wears an SVG filter: feTurbulence makes smooth, low-frequency fractal noise, and feDisplacementMap uses that noise to push the dot's pixels sideways. Every frame the filter's scale is set from how far the pointer moved since the last frame, times the jiggle amount, capped at 600. The scale eases toward that target by 5 percent a frame, so a quick flick builds up a wobble that breaks the dot into a jelly blob and then settles slowly back to a circle. The slow spin turns the noise with it, so the wobble never looks the same twice. With the idle demo on, a virtual pointer darts between random spots while nobody is pointing.

Accessibility and performance

The element has a text label and the dot is hidden from screen readers. With prefers-reduced-motion the dot still follows the pointer, because that is direct input, but the wobble filter, the spin and the idle darts are all off. Nothing flashes. Nothing needs keyboard input; if you use it as a site cursor, keep the system cursor visible too.

Per frame it writes one transform and, only when the value changes, one filter attribute. The filter covers only the dot's own small box, so the cost stays tiny even at large sizes. The loop pauses off screen and in hidden tabs.

SectionFull page
TechniquesPointer interaction
StackVanilla JS, SVG filter, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byRed Jiggling Tracker Explorer by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

How do I use it as a cursor for the whole page?

Make the dot position: fixed, listen for pointermove on window and use clientX and clientY directly. Keep pointer-events: none on the dot so clicks pass through to the page.

Why is the dot see-through?

That is the original look: 0.6 opacity plus a blend mode. On a dark background Lighten keeps it glowing; on a light background switch to Darken, which is what the original pen used on white.

Why does the dot break into pieces when I move very fast?

The displacement map can push pixels further than the filter's own box, so at high scale parts of the dot are sampled from outside it and drop out. Lower the jiggle if you want a softer wobble.

Does it work on touch screens?

Yes. A tap moves the dot, and a sideways drag drags it. Vertical swipes still scroll the page because the element only claims horizontal drags.