Blob Cursor
A gooey blob chases the pointer on a springy leash, stretching out along its path when you move fast and wobbling round again when you stop. Little drops trail behind it and melt back into the blob.
Squishy
Move your pointer, fastHow to use it
- Paste the snippet just before
</head>. - Add
data-blob-cursorto the section the blob should follow the pointer in.
Options
Add any of these attributes to the same element.
data-color | Blob colour. Default #a855f7. |
data-size | Width of the blob, in px. Default 46. |
data-trail | How many drops trail behind it. Default 4; 0 for none. |
data-blend | A CSS blend mode such as multiply or difference, so text shows through the blob. |
data-hide-cursor | Hide the real cursor over the section, on mouse and trackpad only. |
data-autoplay | A pretend pointer swoops around while a real one isn’t in the section. Handy for demos and touch screens. |
The blob sits over the section without catching clicks. On touch screens it swoops over to wherever you tap. With reduced motion turned on, there’s no blob.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Blob Cursor: a gooey blob chases the pointer around an area on a springy
// leash, stretching out along its path when it moves fast and wobbling
// round again when it stops. Smaller drops trail behind and melt into it.
// Usage: <section data-blob-cursor>…</section>
// Optional: data-color (default #a855f7), data-size (blob diameter, px,
// default 46), data-trail (how many drops trail behind, default 4),
// data-blend (a CSS mix-blend-mode such as "multiply" or "difference", so
// text shows through the blob), data-hide-cursor (hide the real cursor over
// the area, on mouse and trackpad only), data-autoplay (a pretend pointer
// swoops around while a real one isn't in the area; handy for demos and
// touch screens).
// The blob sits over the area without catching clicks.
var DEFAULTS = { color: '#a855f7', size: 46, trail: 4 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.blc{position:relative;overflow:hidden}' +
'.blc-layer{position:absolute;inset:0;z-index:2;pointer-events:none}' +
'.blc-drop{position:absolute;left:0;top:0;border-radius:50%;will-change:transform}' +
'.blc-defs{position:absolute;width:0;height:0;overflow:hidden}' +
'@media (hover:hover) and (pointer:fine){.blc-nocursor,.blc-nocursor *{cursor:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
var uid = 0;
function setup(el) {
var d = el.dataset;
var colour = d.color || DEFAULTS.color;
var size = num(d.size, DEFAULTS.size);
var count = Math.max(0, Math.min(10, num(d.trail, DEFAULTS.trail)));
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
el.classList.add('blc');
if (d.hideCursor !== undefined && d.hideCursor !== 'false') el.classList.add('blc-nocursor');
// The goo: blur the drops together, then sharpen the alpha back up so
// overlapping drops merge into one smooth shape.
var id = 'blc-goo' + (++uid);
var blur = Math.max(4, size / 5);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'blc-defs');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML =
'<filter id="' + id + '" x="-50%" y="-50%" width="200%" height="200%">' +
'<feGaussianBlur in="SourceGraphic" stdDeviation="' + blur.toFixed(1) + '" result="b"/>' +
'<feColorMatrix in="b" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9"/>' +
'</filter>';
el.appendChild(svg);
var layer = document.createElement('div');
layer.className = 'blc-layer';
layer.setAttribute('aria-hidden', 'true');
layer.style.filter = 'url(#' + id + ')';
if (d.blend) layer.style.mixBlendMode = d.blend;
el.appendChild(layer);
var drops = [];
for (var i = 0; i <= count; i++) {
var s = size * (1 - i * (0.5 / Math.max(1, count)));
var node = document.createElement('span');
node.className = 'blc-drop';
node.style.width = node.style.height = s + 'px';
node.style.background = colour;
layer.appendChild(node);
drops.push({ node: node, s: s, x: 0, y: 0, vx: 0, vy: 0, sx: 1, a: 0 });
}
var p = { x: 0, y: 0 }, hover = false, hold = 0, visible = false, raf = 0, w = 0, h = 0, t = Math.random() * 6, k = 0, placed = false;
function measure() { w = el.clientWidth; h = el.clientHeight; }
measure();
new ResizeObserver(measure).observe(el);
function aim(cx, cy) {
var b = el.getBoundingClientRect();
p.x = cx - b.left; p.y = cy - b.top;
if (!placed || k < 0.05) place();
}
function place() {
placed = true;
drops.forEach(function (dr) { dr.x = p.x; dr.y = p.y; dr.vx = dr.vy = 0; });
}
function frame() {
raf = 0;
if (!visible) return;
var touchHeld = Date.now() < hold;
var fake = auto && !hover && !touchHeld;
if (fake) {
// Lazy loops with the odd quick swoop, to show the stretch.
t += 0.012 + 0.03 * Math.pow(Math.max(0, Math.sin(t * 0.7)), 6);
p.x = w * (0.5 + 0.36 * Math.sin(t * 1.3));
p.y = h * (0.5 + 0.3 * Math.sin(t * 2.1 + 0.8));
if (!placed) place();
}
var on = hover || touchHeld || fake;
k += ((on ? 1 : 0) - k) * 0.12;
for (var i = 0; i < drops.length; i++) {
var dr = drops[i];
// The blob springs to the pointer; each drop springs to the one before.
var tx = i ? drops[i - 1].x : p.x, ty = i ? drops[i - 1].y : p.y;
var stiff = i ? 0.3 : 0.16, damp = i ? 0.55 : 0.74;
dr.vx = (dr.vx + (tx - dr.x) * stiff) * damp;
dr.vy = (dr.vy + (ty - dr.y) * stiff) * damp;
dr.x += dr.vx; dr.y += dr.vy;
var v = Math.hypot(dr.vx, dr.vy);
var stretch = Math.min(0.9, v / (size * 0.35));
dr.sx += (1 + stretch - dr.sx) * 0.3;
var ang = v > 0.2 ? Math.atan2(dr.vy, dr.vx) * 180 / Math.PI : (dr.ang || 0);
dr.ang = ang;
var sc = k;
dr.node.style.transform = 'translate(' + (dr.x - dr.s / 2).toFixed(1) + 'px,' + (dr.y - dr.s / 2).toFixed(1) + 'px) rotate(' + ang.toFixed(1) + 'deg) scale(' +
(dr.sx * sc).toFixed(3) + ',' + (sc / Math.sqrt(dr.sx)).toFixed(3) + ')';
}
if (on || k > 0.01) raf = requestAnimationFrame(frame);
else { k = 0; placed = false; drops.forEach(function (dr) { dr.node.style.transform = 'scale(0)'; }); }
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
drops.forEach(function (dr) { dr.node.style.transform = 'scale(0)'; });
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 3000;
else hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
hover = false;
wake();
});
// Touch: the blob swoops over to the finger.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 3000;
var b = el.getBoundingClientRect();
p.x = e.clientX - b.left; p.y = e.clientY - b.top;
if (!placed) place();
wake();
});
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
}).observe(el);
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-blob-cursor]').forEach(function (el) {
if (el._blc) return;
el._blc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-blob-cursor>…</section>
<!-- inverts the text it passes over: -->
<section data-blob-cursor data-color="#fff" data-blend="difference">…</section>