Copied!
Full page

Cursor follower with states

A dot and lagging ring cursor that grows over links, shows a label on cards and sliders, and turns magnetic on buttons.

← 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 "Cursor follower with states" for my site using the Enrichr Library element at https://enrichr.dev/elements/cursor-follower-states.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/cursor-follower-states.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:
- background: #0c1e18
- accent: #e6ff55
- ringSize: 36
- smoothing: 0.16
- cardLabel: View

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/cursor-follower-states.json.

Code

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

HTML

<div class="ef-cfs">
  <div class="ef-cfs__zone">
    <p class="ef-cfs__eyebrow">Move over each target</p>
    <div class="ef-cfs__row">
      <button type="button" class="ef-cfs__btn" data-cfs="magnet">Get started</button>
      <a href="#" class="ef-cfs__link" data-cfs="grow">Read the story</a>
    </div>
    <div class="ef-cfs__grid">
      <a href="#" class="ef-cfs__card" data-cfs="label" data-label="View"><span class="ef-cfs__art" aria-hidden="true"></span><span class="ef-cfs__cap">Case study</span></a>
      <div class="ef-cfs__track" data-cfs="label" data-label="Drag"><div class="ef-cfs__knob" tabindex="0" role="slider" aria-label="Demo slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow="30"></div></div>
    </div>
    <div class="ef-cfs__ring" aria-hidden="true"><span class="ef-cfs__lab"></span></div>
    <div class="ef-cfs__dot" aria-hidden="true"></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-cfs { width: 100%; }
.ef-cfs__zone { --ef-acc: #e6ff55; --ef-r: 999px; position: relative; isolation: isolate; overflow: hidden; width: 100%; min-height: 18rem; box-sizing: border-box; padding: 1.6rem; border-radius: 1rem; display: flex; flex-direction: column; justify-content: center; gap: 1.1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-cfs__eyebrow { margin: 0; font-size: 0.72rem; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.6; }
.ef-cfs__row { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 1.8rem; }
.ef-cfs__btn { appearance: none; border: 1.5px solid currentColor; background: transparent; color: inherit; font: inherit; font-weight: 700; padding: 0.85rem 1.7rem; border-radius: 999px; cursor: pointer; will-change: transform; transition: transform 380ms cubic-bezier(.2,.8,.2,1); }
.ef-cfs__link { color: inherit; font-weight: 600; font-size: 1.1rem; text-decoration: underline; text-underline-offset: 0.3em; text-decoration-thickness: 1px; }
.ef-cfs__btn:focus-visible, .ef-cfs__link:focus-visible, .ef-cfs__card:focus-visible, .ef-cfs__knob:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 3px; }
.ef-cfs__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.9rem; align-items: stretch; }
.ef-cfs__card { display: flex; flex-direction: column; gap: 0.5rem; color: inherit; text-decoration: none; border-radius: 0.8rem; }
.ef-cfs__art { display: block; aspect-ratio: 4 / 3; border-radius: 0.8rem; background: radial-gradient(circle at 25% 30%, #fbf8f1 0 14%, transparent 15%), radial-gradient(circle at 80% 85%, rgba(255,255,255,.35) 0 22%, transparent 23%), linear-gradient(135deg, #005236, #0c3a2b 55%, #1d6b4c); transition: transform 500ms cubic-bezier(.2,.8,.2,1); }
.ef-cfs__card:hover .ef-cfs__art { transform: scale(0.97); }
.ef-cfs__cap { font-size: 0.8rem; opacity: 0.75; }
.ef-cfs__track { position: relative; min-height: 5rem; border-radius: 0.8rem; border: 1px dashed currentColor; border-color: color-mix(in srgb, currentColor 40%, transparent); touch-action: none; }
.ef-cfs__knob { position: absolute; top: 50%; left: calc(0.6rem + (100% - 3.8rem) * var(--ef-k, 0.3)); width: 2.6rem; height: 2.6rem; margin-top: -1.3rem; border-radius: 50%; background: var(--ef-acc); }
.ef-cfs__ring, .ef-cfs__dot { display: none; position: absolute; left: 0; top: 0; z-index: 3; pointer-events: none; opacity: 0; will-change: transform; }
.ef-cfs__ring { align-items: center; justify-content: center; box-sizing: border-box; border: 1.5px solid var(--ef-acc); border-radius: 50%; transition: opacity 180ms linear, background-color 240ms, border-radius 240ms; }
.ef-cfs__dot { width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--ef-acc); transition: opacity 180ms linear, scale 200ms; }
.ef-cfs__lab { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; color: var(--ef-bg, #0c1e18); opacity: 0; transform: scale(0.6); transition: opacity 200ms, transform 300ms cubic-bezier(.2,.8,.2,1); }
.ef-cfs__zone.is-on, .ef-cfs__zone.is-on * { cursor: none; }
.ef-cfs__zone.is-on .ef-cfs__ring, .ef-cfs__zone.is-on .ef-cfs__dot { display: flex; }
.ef-cfs__zone.is-on.is-in .ef-cfs__ring, .ef-cfs__zone.is-on.is-in .ef-cfs__dot { opacity: 1; }
.ef-cfs__zone[data-mode="grow"] .ef-cfs__ring { background: color-mix(in srgb, var(--ef-acc) 22%, transparent); }
.ef-cfs__zone[data-mode="label"] .ef-cfs__ring { background: var(--ef-acc); }
.ef-cfs__zone[data-mode="label"] .ef-cfs__lab { opacity: 1; transform: scale(1); }
.ef-cfs__zone[data-mode="label"] .ef-cfs__dot, .ef-cfs__zone[data-mode="magnet"] .ef-cfs__dot { scale: 0; }
.ef-cfs__zone[data-mode="magnet"] .ef-cfs__ring { border-radius: var(--ef-r); }
.ef-cfs__zone.is-down .ef-cfs__dot { scale: 0.6; }
@media (prefers-reduced-motion: reduce) { .ef-cfs__btn, .ef-cfs__art, .ef-cfs__lab { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Cursor follower with states: a dot plus a lagging ring that grows over links, shows a label over image cards and the drag track,
// and becomes magnetic over the button (the ring morphs to the button and the button leans toward the pointer). Fine pointers only;
// touch screens keep the normal cursor. Reduced motion removes lag and magnetic pull.
// mount(root, { eyebrow, background, textColor, accent, ringSize (px), smoothing (0.05-1), cardLabel, dragLabel })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
  var zone = root.querySelector(".ef-cfs__zone");
  var ring = root.querySelector(".ef-cfs__ring");
  var lab = root.querySelector(".ef-cfs__lab");
  var dot = root.querySelector(".ef-cfs__dot");
  var eyebrow = root.querySelector(".ef-cfs__eyebrow");
  var btn = root.querySelector(".ef-cfs__btn");
  var card = root.querySelector(".ef-cfs__card");
  var track = root.querySelector(".ef-cfs__track");
  var knob = root.querySelector(".ef-cfs__knob");
  var opts = {}, raf = 0, visible = true, io = null, inside = false;
  var px = 0, py = 0, dx = 0, dy = 0, rx = 0, ry = 0, rw = 36, rh = 36;
  var cur = null, mode = "none", pressed = false, k = 0.3, dragging = false;

  function setMode(t) {
    cur = t;
    mode = t ? t.getAttribute("data-cfs") : "none";
    zone.setAttribute("data-mode", mode);
    lab.textContent = mode === "label" ? t.getAttribute("data-label") || "" : "";
    if (mode === "magnet") zone.style.setProperty("--ef-r", getComputedStyle(t).borderTopLeftRadius || "999px");
    if (btn && t !== btn) btn.style.transform = "";
  }
  function frame() {
    raf = requestAnimationFrame(frame);
    var zr = zone.getBoundingClientRect();
    var tw = opts.ringSize, th = opts.ringSize, tx = px, ty = py;
    if (mode === "grow") { tw = th = opts.ringSize * 1.9; }
    else if (mode === "label") { tw = th = Math.max(opts.ringSize * 2.5, 72); }
    else if (mode === "magnet" && cur) {
      var br = cur.getBoundingClientRect();
      var cx = br.left - zr.left + br.width / 2, cy = br.top - zr.top + br.height / 2;
      tw = br.width + 16; th = br.height + 16;
      tx = cx + (px - cx) * 0.25; ty = cy + (py - cy) * 0.25;
    }
    if (pressed) { tw *= 0.82; th *= 0.82; }
    var f = reduce ? 1 : opts.smoothing;
    rx += (tx - rx) * f; ry += (ty - ry) * f;
    rw += (tw - rw) * (reduce ? 1 : 0.2); rh += (th - rh) * (reduce ? 1 : 0.2);
    dx += (px - dx) * (reduce ? 1 : 0.6); dy += (py - dy) * (reduce ? 1 : 0.6);
    ring.style.width = rw.toFixed(1) + "px"; ring.style.height = rh.toFixed(1) + "px";
    ring.style.transform = "translate3d(" + (rx - rw / 2).toFixed(1) + "px," + (ry - rh / 2).toFixed(1) + "px,0)";
    dot.style.transform = "translate3d(" + dx.toFixed(1) + "px," + dy.toFixed(1) + "px,0)";
  }
  function run() { if (!raf && inside && visible) raf = requestAnimationFrame(frame); }
  function halt() { cancelAnimationFrame(raf); raf = 0; }
  function move(e) {
    var zr = zone.getBoundingClientRect();
    px = e.clientX - zr.left; py = e.clientY - zr.top;
    if (!inside) { inside = true; dx = rx = px; dy = ry = py; zone.classList.add("is-in"); run(); }
    var t = e.target && e.target.closest ? e.target.closest("[data-cfs]") : null;
    if (t !== cur) setMode(t);
    if (mode === "magnet" && btn && !reduce) {
      var br = btn.getBoundingClientRect();
      var ox = (e.clientX - (br.left + br.width / 2)) * 0.22, oy = (e.clientY - (br.top + br.height / 2)) * 0.3;
      btn.style.transform = "translate(" + ox.toFixed(1) + "px," + oy.toFixed(1) + "px)";
    }
    if (dragging) setKnob(e.clientX);
  }
  function leave() { inside = false; zone.classList.remove("is-in"); pressed = false; zone.classList.remove("is-down"); setMode(null); halt(); }
  function down() { pressed = true; zone.classList.add("is-down"); }
  function up() { pressed = false; zone.classList.remove("is-down"); }
  function setK(v) { k = Math.max(0, Math.min(1, v)); track.style.setProperty("--ef-k", k.toFixed(3)); knob.setAttribute("aria-valuenow", Math.round(k * 100)); }
  function setKnob(clientX) {
    var tr = track.getBoundingClientRect(), kr = knob.getBoundingClientRect();
    var pad = kr.width / 2 + 0.6 * 16;
    setK((clientX - tr.left - pad) / Math.max(1, tr.width - pad * 2));
  }
  function tdown(e) { dragging = true; try { track.setPointerCapture(e.pointerId); } catch (x) {} setKnob(e.clientX); }
  function tup() { dragging = false; }
  function kkey(e) {
    var d = e.key === "ArrowRight" || e.key === "ArrowUp" ? 0.05 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -0.05 : 0;
    if (d) { e.preventDefault(); setK(k + d); }
  }
  function nolink(e) { e.preventDefault(); }

  function update(next) {
    opts = Object.assign({ eyebrow: "Move over each target", background: "#0c1e18", textColor: "#fbf8f1", accent: "#e6ff55", ringSize: 36, smoothing: 0.16, cardLabel: "View", dragLabel: "Drag" }, opts, next || {});
    zone.style.background = opts.background;
    zone.style.color = opts.textColor;
    zone.style.setProperty("--ef-acc", opts.accent);
    zone.style.setProperty("--ef-bg", opts.background);
    eyebrow.textContent = opts.eyebrow;
    card.setAttribute("data-label", opts.cardLabel);
    track.setAttribute("data-label", opts.dragLabel);
    if (cur && mode === "label") lab.textContent = cur.getAttribute("data-label") || "";
  }
  update(o);
  setK(0.3);
  var links = root.querySelectorAll("a[href='#']");
  Array.prototype.forEach.call(links, function (a) { a.addEventListener("click", nolink); });
  knob.addEventListener("keydown", kkey);
  track.addEventListener("pointerdown", tdown);
  track.addEventListener("pointerup", tup);
  track.addEventListener("pointercancel", tup);
  if (fine) {
    zone.classList.add("is-on");
    zone.addEventListener("pointermove", move);
    zone.addEventListener("pointerleave", leave);
    zone.addEventListener("pointerdown", down);
    window.addEventListener("pointerup", up);
    if ("IntersectionObserver" in window) {
      io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; if (visible) run(); else halt(); });
      io.observe(zone);
    }
  }
  return {
    update: update,
    destroy: function () {
      halt();
      if (io) io.disconnect();
      zone.removeEventListener("pointermove", move);
      zone.removeEventListener("pointerleave", leave);
      zone.removeEventListener("pointerdown", down);
      window.removeEventListener("pointerup", up);
      knob.removeEventListener("keydown", kkey);
      track.removeEventListener("pointerdown", tdown);
      track.removeEventListener("pointerup", tup);
      track.removeEventListener("pointercancel", tup);
      Array.prototype.forEach.call(links, function (a) { a.removeEventListener("click", nolink); });
      if (btn) btn.style.transform = "";
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"background":"#0c1e18","accent":"#e6ff55","ringSize":36,"smoothing":0.16,"cardLabel":"View"});

How it works

A small dot follows the pointer almost directly while a larger ring eases toward it a little more slowly each frame. Elements carry a data-cfs attribute that names their state. grow enlarges and tints the ring, label fills it and prints the element data-label inside, and magnet morphs the ring to the size and corner radius of the button and pulls the ring and the button a fraction of the way toward the pointer. The ring size and position are interpolated in JavaScript so changes between states are smooth rather than snapped. The loop runs only while the pointer is inside the area and the area is on screen.

Accessibility and performance

The custom cursor is added only for devices with a hover-capable fine pointer, so touch screens keep their normal behaviour and see nothing extra. Every target is a real button, link or focusable slider with a visible focus outline and keyboard control (the slider responds to arrow keys), and the cursor layers are hidden from assistive technology. Reduced motion removes the lag and the magnetic pull.

Two transform writes and two size writes per frame while the pointer is inside, and nothing at all otherwise. Size changes on the ring touch only a small absolutely positioned element, so layout cost is negligible.

SectionFull page
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byAnimated Custom Cursor Effects by Manoela Ilic on Codrops. 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 is this different from the blend-mode cursor?

The blend-mode cursor is a single inverting dot. This one has two layers, a dot and a ring, and separate states per target type: growing, labelled and magnetic.

How do I add my own target?

Add data-cfs="grow", "label" or "magnet" to any element inside the area. For a label, also set data-label to the text you want shown in the ring.

Does it work on touch screens?

It is switched off there. The check uses the hover and fine pointer media features, so phones and most tablets keep the native pointer behaviour.