Copied!
Full page

Blend-mode cursor

A custom cursor dot that inverts what is beneath it and grows over links, for fine pointers only.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

HeroElementVanilla JS + 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 "Blend-mode cursor" for my site using the Enrichr Library element at https://enrichr.dev/elements/blend-mode-cursor.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/blend-mode-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: Move the pointer
- items: Work, About, Contact
- size: 28
- hoverScale: 3
- smoothing: 0.18
- background: #ff5a36
- textColor: #10261f
- dotColor: #ffffff

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/blend-mode-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-cur">
  <div class="ef-cur__zone">
    <h3 class="ef-cur__t"></h3>
    <ul class="ef-cur__list"></ul>
    <div class="ef-cur__dot" aria-hidden="true"></div>
  </div>
</div>

CSS

.ef-cur__zone { position: relative; isolation: isolate; overflow: hidden; min-height: 16rem; padding: 2rem 1.6rem; border-radius: 1rem; display: flex; flex-direction: column; justify-content: center; gap: 1rem; }
.ef-cur__zone.is-on, .ef-cur__zone.is-on a, .ef-cur__zone.is-on button { cursor: none; }
.ef-cur__t { margin: 0; font-size: clamp(1.8rem, 5vw, 3.2rem); font-weight: 800; letter-spacing: -0.02em; }
.ef-cur__list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1.4rem; }
.ef-cur__list a { color: inherit; font-size: 1.1rem; font-weight: 600; text-decoration: none; }
.ef-cur__list a:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ef-cur__dot { position: absolute; left: 0; top: 0; z-index: 2; width: var(--ef-size, 28px); height: var(--ef-size, 28px); border-radius: 50%; background: #fff; mix-blend-mode: difference; pointer-events: none; opacity: 0; transition: opacity 150ms linear; will-change: transform; }
.ef-cur__zone.is-on.is-inside .ef-cur__dot { opacity: 1; }

JavaScript

// Blend-mode cursor: a dot follows the pointer inside an area, inverts whatever is beneath it (mix-blend-mode: difference)
// and grows over links, buttons and anything marked data-cursor. Only used with a fine pointer; touch screens and
// reduced motion keep the normal cursor (reduced motion also removes the lag).
// mount(root, { title, items (comma separated link labels), size (px), hoverScale, smoothing (0.05-1), background, textColor, dotColor })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var fine = window.matchMedia("(pointer: fine)").matches;
  var zone = root.querySelector(".ef-cur__zone");
  var dot = root.querySelector(".ef-cur__dot");
  var title = root.querySelector(".ef-cur__t");
  var list = root.querySelector(".ef-cur__list");
  var opts = {}, raf = 0, x = 0, y = 0, tx = 0, ty = 0, s = 1, ts = 1;

  function frame() {
    raf = requestAnimationFrame(frame);
    var k = reduce ? 1 : opts.smoothing;
    x += (tx - x) * k;
    y += (ty - y) * k;
    s += (ts - s) * (reduce ? 1 : 0.2);
    dot.style.transform = "translate3d(" + (x - opts.size / 2).toFixed(1) + "px," + (y - opts.size / 2).toFixed(1) + "px,0) scale(" + s.toFixed(3) + ")";
  }
  function move(e) {
    var r = zone.getBoundingClientRect();
    tx = e.clientX - r.left;
    ty = e.clientY - r.top;
    if (!zone.classList.contains("is-inside")) { x = tx; y = ty; zone.classList.add("is-inside"); }
    var hot = e.target && e.target.closest && e.target.closest("a,button,[data-cursor]");
    ts = hot ? opts.hoverScale : 1;
  }
  function leave() { zone.classList.remove("is-inside"); ts = 1; }
  function update(next) {
    opts = Object.assign({ title: "Move the pointer", items: "Work, About, Contact", size: 28, hoverScale: 3, smoothing: 0.18, background: "#ff5a36", textColor: "#10261f", dotColor: "#ffffff" }, opts, next || {});
    zone.style.background = opts.background;
    zone.style.color = opts.textColor;
    zone.style.setProperty("--ef-size", opts.size + "px");
    dot.style.background = opts.dotColor;
    title.textContent = opts.title;
    list.textContent = "";
    opts.items.split(",").map(function (t) { return t.trim(); }).filter(Boolean).forEach(function (t) {
      var li = document.createElement("li");
      var a = document.createElement("a");
      a.href = "#";
      a.textContent = t;
      a.addEventListener("click", function (e) { e.preventDefault(); });
      li.appendChild(a);
      list.appendChild(li);
    });
  }
  update(o);
  if (fine) {
    zone.classList.add("is-on");
    zone.addEventListener("pointermove", move);
    zone.addEventListener("pointerleave", leave);
    raf = requestAnimationFrame(frame);
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); zone.removeEventListener("pointermove", move); zone.removeEventListener("pointerleave", leave); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Move the pointer","items":"Work, About, Contact","size":28,"hoverScale":3,"smoothing":0.18,"background":"#ff5a36","textColor":"#10261f","dotColor":"#ffffff"});

How it works

The dot is positioned with a transform that eases toward the pointer each frame. It uses mix-blend-mode: difference, so a white dot shows the inverse of whatever is under it, on any background. When the pointer is over a link, a button or anything marked data-cursor, the target scale rises and the dot grows. The effect is switched on only when the device has a fine pointer, so touch screens never see a stray dot.

Accessibility and performance

The real cursor is hidden only on devices with a fine pointer; touch screens and anyone who asks for reduced motion keep the normal cursor behaviour (reduced motion also removes the lag). Links keep their native focus outlines and keyboard behaviour, and the dot is hidden from assistive technology.

One transform write per frame while a fine pointer is in the area. Nothing runs on touch devices. Blend modes can be costly over large video or canvas layers, so test it over your real content.

SectionFull page
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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

Why hide the real cursor at all?

A custom dot next to the real cursor looks like a bug. The element hides the native cursor only inside the area and only for fine pointers, and the native cursor returns on leave.

Does mix-blend-mode work everywhere?

It is supported by all current browsers. Where it is not, the dot shows as a plain white circle, which still works as a cursor.

Can I make the whole page use it?

Yes. Use the page body as the area and change the dot to position: fixed. Keep the native cursor for text inputs.