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 "Ripple invert cursor" for my site using the Enrichr Library element at https://enrichr.dev/elements/invert-ripple-cursor.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/invert-ripple-cursor.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:
- title: Invert
- subtitle: Move the pointer fast to ripple
- size: 200
- ripple: 600
- smoothing: 1
- spin: 0.3
- background: #ffffff
- textColor: #000000
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/invert-ripple-cursor.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-irc"><div class="ef-irc__zone"><h2 class="ef-irc__t"></h2><p class="ef-irc__s"></p><div class="ef-irc__blob" aria-hidden="true"></div></div></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-irc__zone { position: relative; isolation: isolate; overflow: hidden; min-height: 20rem; padding: 2rem 1.2rem; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; border-radius: 1rem; touch-action: pan-y; container-type: inline-size; }
.ef-irc .ef-irc__t { margin: 0; font-size: clamp(2.6rem, 13vw, 8rem); font-size: clamp(2.6rem, 17cqi, 8rem); font-weight: 900; line-height: 1; letter-spacing: -0.05em; text-transform: uppercase; overflow-wrap: anywhere; }
.ef-irc .ef-irc__s { margin: 1rem 0 0; opacity: 1; font-size: clamp(1rem, 2.4vw, 1.5rem); font-weight: 500; }
.ef-irc__blob { position: absolute; left: 0; top: 0; z-index: 2; width: var(--ef-size, 200px); height: var(--ef-size, 200px); border-radius: 50%; background: #fff; mix-blend-mode: difference; pointer-events: none; will-change: transform; }JavaScript
// Enrichr Library · https://enrichr.dev
// Ripple invert cursor: a big white circle follows the pointer and inverts the colours beneath it (mix-blend-mode: difference).
// Move fast and its edge ripples like liquid: an SVG turbulence + displacement filter whose strength follows pointer speed, then settles.
// mount(root, { title, subtitle, size (px), ripple (max distortion 0-600), smoothing (0.05-1, 1 = no lag), spin (degrees per frame), background, textColor })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var zone = root.querySelector(".ef-irc__zone"), blob = root.querySelector(".ef-irc__blob");
var title = root.querySelector(".ef-irc__t"), sub = root.querySelector(".ef-irc__s");
var NS = "http://www.w3.org/2000/svg", id = "ef-irc-" + Math.random().toString(36).slice(2, 8);
var svg = document.createElementNS(NS, "svg"), f = document.createElementNS(NS, "filter");
var turb = document.createElementNS(NS, "feTurbulence"), disp = document.createElementNS(NS, "feDisplacementMap");
svg.setAttribute("aria-hidden", "true"); svg.setAttribute("width", "0"); svg.setAttribute("height", "0");
svg.style.cssText = "position:absolute;width:0;height:0;pointer-events:none";
f.setAttribute("id", id); f.setAttribute("x", "-50%"); f.setAttribute("y", "-50%"); f.setAttribute("width", "200%"); f.setAttribute("height", "200%");
turb.setAttribute("type", "fractalNoise"); turb.setAttribute("baseFrequency", "0.015"); turb.setAttribute("numOctaves", "1"); turb.setAttribute("result", "noise");
disp.setAttribute("in", "SourceGraphic"); disp.setAttribute("in2", "noise"); disp.setAttribute("scale", "0");
disp.setAttribute("xChannelSelector", "R"); disp.setAttribute("yChannelSelector", "G");
f.appendChild(turb); f.appendChild(disp); svg.appendChild(f); zone.appendChild(svg);
var opts = {}, raf = 0, visible = true, io = null, ro = null, placed = false;
var tx = 0, ty = 0, x = 0, y = 0, lx = 0, ly = 0, scale = 0, target = 0, angle = 0, shown = -1;
function center() { if (placed) return; tx = x = lx = zone.clientWidth / 2; ty = y = ly = zone.clientHeight / 2; }
function paint() {
blob.style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) translate(-50%,-50%) rotate(" + angle.toFixed(1) + "deg)";
var s = Math.round(scale);
if (s !== shown) { shown = s; disp.setAttribute("scale", String(s)); blob.style.filter = s > 0 ? "url(#" + id + ")" : ""; }
}
function frame() {
raf = 0;
if (!visible) return;
var k = reduce ? 1 : opts.smoothing;
x += (tx - x) * k;
y += (ty - y) * k;
var dx = x - lx, dy = y - ly;
lx = x; ly = y;
var speed = Math.sqrt(dx * dx + dy * dy);
target = reduce ? 0 : Math.min(speed * 20, opts.ripple);
scale += (target - scale) * 0.05;
if (!reduce) angle = (angle + opts.spin) % 360;
paint();
var moving = Math.abs(tx - x) > 0.3 || Math.abs(ty - y) > 0.3 || scale > 0.5 || target > 0;
if (moving) raf = requestAnimationFrame(frame);
else if (scale !== 0) { scale = 0; paint(); }
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
function move(e) {
var r = zone.getBoundingClientRect();
tx = e.clientX - r.left;
ty = e.clientY - r.top;
if (!placed) { placed = true; if (reduce) { x = lx = tx; y = ly = ty; } }
wake();
}
function update(next) {
opts = Object.assign({ title: "Invert", subtitle: "Move the pointer fast to ripple", size: 200, ripple: 600, smoothing: 1, spin: 0.3, background: "#ffffff", textColor: "#000000" }, opts, next || {});
opts.size = Number(opts.size); opts.ripple = Number(opts.ripple); opts.smoothing = Math.max(0.02, Math.min(1, Number(opts.smoothing))); opts.spin = Number(opts.spin);
zone.style.background = opts.background;
zone.style.color = opts.textColor;
title.style.color = sub.style.color = opts.textColor;
zone.style.setProperty("--ef-size", opts.size + "px");
title.textContent = opts.title;
sub.textContent = opts.subtitle;
sub.hidden = !opts.subtitle;
center();
paint();
}
update(o);
zone.addEventListener("pointermove", move);
zone.addEventListener("pointerdown", move);
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { center(); paint(); }); ro.observe(zone); }
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) wake(); }); io.observe(zone); }
return {
update: update,
destroy: function () {
cancelAnimationFrame(raf); raf = 0;
if (io) io.disconnect(); if (ro) ro.disconnect();
zone.removeEventListener("pointermove", move); zone.removeEventListener("pointerdown", move);
if (svg.parentNode) svg.parentNode.removeChild(svg);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Invert","subtitle":"Move the pointer fast to ripple","size":200,"ripple":600,"smoothing":1,"spin":0.3,"background":"#ffffff","textColor":"#000000"});How it works
A white circle sits above the content with mix-blend-mode: difference, so wherever it overlaps, white turns black and black turns white. The circle also carries an SVG filter: feTurbulence draws a soft, low-frequency noise field and feDisplacementMap pushes the circle's pixels around by that noise. Each frame the script measures how far the circle moved since the last frame, multiplies that speed into a target distortion (capped by the ripple setting) and eases the filter's scale toward it by 5 percent a frame, so a fast flick makes the edge boil and it then settles back into a clean circle. A slow constant spin turns the noise pattern while it is distorted, which keeps the ripple from looking frozen. The filter is only attached while the distortion is above zero.
Accessibility and performance
The circle is decorative, hidden from assistive technology and ignores pointer events, so links and text underneath stay usable and selectable. The headline is real text. With reduced motion the circle jumps straight to the pointer with no lag, ripple or spin. Inverting a large area can be harsh: keep it to a hero or a single section rather than a whole page.
One requestAnimationFrame loop runs only while the circle is moving or rippling and stops once it settles; it also pauses when the element is off screen. Each frame writes one transform, and the filter scale attribute only when its rounded value changes. Large displacement values on a big circle cost more to paint, so lower the ripple strength if a low-end phone stutters.
| Section | Full page |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, SVG filter, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | inverted mouse filter with deformation by Homeless Romantic Creative 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.
More elements
FAQ
Why does the ripple only show when I move fast?
The distortion is driven by speed. Standing still or moving slowly keeps the target near zero, so the circle stays round; a quick flick spikes it and it eases back down.
Can I use a colour other than white?
Difference blending with white gives a true invert. Any other colour gives a colour shift instead: for example #00ffff turns white into red. Change the background and text colours to taste.
Does it work on touch screens?
Yes. A tap or a sideways drag moves the circle; vertical swipes still scroll the page.