← Hover & cursor

Custom Cursor

Inside an area, the pointer becomes a small dot with a ring trailing after it on a spring. Over a link the ring swells into a disc that inverts whatever is beneath it, an element with data-cursor="View" gets a bigger disc with that word in it, and pressing squeezes it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-custom-cursor to the section it should cover, or to <body> for the whole page.
  3. Optionally give images or cards a word to show, e.g. data-cursor="View".

Options

Add any of these attributes to the same element.

data-colorCursor colour. By default it’s white and inverts what’s under it, so it shows up on light and dark alike.
data-sizeRing size, in px. Default 36.
data-autoplayA pretend pointer drifts between the links while a real one isn’t in the area. Handy for demos.

It reacts to links, buttons, form fields and anything with data-cursor. Touch screens keep their normal behaviour.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Custom Cursor: inside an area, the pointer becomes a small dot with a ring
    // that trails behind it. Over links and buttons the ring swells into a disc
    // and the dot tucks away; give an element data-cursor="View" and the disc
    // grows bigger still and shows that word. Pressing squeezes the ring.
    // Usage: <section data-custom-cursor>…links, buttons, images…</section>
    //    or: <body data-custom-cursor> for the whole page
    // Optional: data-color (cursor colour; by default it is white and inverts
    // whatever is under it), data-size (ring size in px, default 36),
    // data-autoplay (a pretend pointer wanders over the links while no real
    // one is in the area; handy for demos).
    // Touch screens keep their normal behaviour.
    var DEFAULTS = { size: 36 };
    var TARGETS = 'a, button, input, select, textarea, label, summary, [role="button"], [data-cursor]';

    var css =
      '.ccr-hide, .ccr-hide *{cursor:none !important}' +
      '.ccr-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;border-radius:inherit}' +
      '.ccr-dot, .ccr-ring{position:absolute;left:0;top:0;pointer-events:none;border-radius:50%;opacity:0;translate:-50% -50%}' +
      '.ccr-fixed{position:fixed;z-index:2147483647}' +
      '.ccr-dot{width:8px;height:8px;background:var(--ccr-color)}' +
      '.ccr-ring{display:grid;place-items:center;border:1.5px solid var(--ccr-color);color:#000;' +
      'font:800 15px/1 system-ui,sans-serif;letter-spacing:.01em;white-space:nowrap}' +
      '.ccr-blend{mix-blend-mode:difference}' +
      '.ccr-ring span{opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var size = +d.size || DEFAULTS.size;
      var page = el === document.body || el === document.documentElement;
      var fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      if (!fine && !autoplay) return;

      // On the whole page the cursor is fixed to the window; in an area it lives
      // in a layer over the area, so it never escapes it.
      var host = document.body;
      if (!page) {
        if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
        host = document.createElement('div');
        host.className = 'ccr-layer';
        host.setAttribute('aria-hidden', 'true');
        el.appendChild(host);
      }
      var dot = document.createElement('div');
      var ring = document.createElement('div');
      var label = document.createElement('span');
      dot.className = 'ccr-dot';
      ring.className = 'ccr-ring';
      ring.appendChild(label);
      [dot, ring].forEach(function (n) {
        n.setAttribute('aria-hidden', 'true');
        n.style.setProperty('--ccr-color', d.color || '#fff');
        if (!d.color) n.classList.add('ccr-blend');
        if (page) n.classList.add('ccr-fixed');
        host.appendChild(n);
      });
      ring.style.width = ring.style.height = size + 'px';

      // The dot keeps up with the pointer; the ring lags behind on a spring.
      var dotPos = anime.createAnimatable(dot, { x: 60, y: 60, ease: 'out(2)' });
      var ringPos = anime.createAnimatable(ring, { x: 420, y: 420, ease: 'out(3)' });
      var shown = false, hot = null, pressed = false, real = false;

      function show(on) {
        if (on === shown) return;
        shown = on;
        anime.animate([dot, ring], { opacity: on ? 1 : 0, duration: 250, ease: 'out(2)' });
      }

      // Grow over a link, bigger still with a word, back to a ring elsewhere.
      function target(t) {
        if (t === hot) return;
        hot = t;
        var word = t && t.dataset.cursor ? t.dataset.cursor : '';
        if (word) label.textContent = word;
        var s = word ? size * 2.4 : t ? size * 1.7 : size;
        anime.animate(ring, {
          width: s,
          height: s,
          backgroundColor: t ? (d.color || '#fff') : 'rgba(255, 255, 255, 0)',
          duration: 500,
          ease: 'outElastic(1, .7)'
        });
        anime.animate(label, { opacity: word ? 1 : 0, scale: word ? [0.6, 1] : 0.6, duration: 300, ease: 'out(3)' });
        anime.animate(dot, { scale: t ? 0 : 1, duration: 250, ease: 'out(3)' });
      }

      function press(down) {
        pressed = down;
        anime.animate(ring, { scale: down ? 0.78 : 1, duration: down ? 140 : 600, ease: down ? 'out(3)' : 'outElastic(1, .5)' });
      }

      // Move the cursor to (x, y) on screen, and check what's under it.
      function moveTo(x, y, under) {
        var r = page ? { left: 0, top: 0 } : host.getBoundingClientRect();
        // (Always pass the duration: an animatable keeps the last one it was given.)
        dotPos.x(x - r.left, 60);
        dotPos.y(y - r.top, 60);
        ringPos.x(x - r.left, 420);
        ringPos.y(y - r.top, 420);
        var t = under && under.closest ? under.closest(TARGETS) : null;
        target(t && el.contains(t) && t !== el ? t : null);
        show(true);
      }

      if (fine) {
        el.classList.add('ccr-hide');
        el.addEventListener('pointerenter', function (e) {
          if (e.pointerType !== 'mouse') return;
          real = true;
          if (wander) wander.pause();
          // Jump straight to the pointer rather than flying in from the last spot.
          var r = page ? { left: 0, top: 0 } : host.getBoundingClientRect();
          dotPos.x(e.clientX - r.left, 0); dotPos.y(e.clientY - r.top, 0);
          ringPos.x(e.clientX - r.left, 0); ringPos.y(e.clientY - r.top, 0);
        });
        el.addEventListener('pointermove', function (e) {
          if (e.pointerType !== 'mouse') return;
          real = true;
          moveTo(e.clientX, e.clientY, e.target);
        });
        el.addEventListener('pointerleave', function (e) {
          if (e.pointerType !== 'mouse') return;
          real = false;
          target(null);
          if (pressed) press(false);
          if (wander && visible) wander.play(); else show(false);
        });
        el.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') press(true); });
        el.addEventListener('pointerup', function (e) { if (e.pointerType === 'mouse') press(false); });
      }

      // A pretend pointer that drifts from link to link, pausing and clicking.
      var wander = null, visible = false, stop = 0;
      var pos = { x: 0.5, y: 0.5 };

      function follow() {
        if (real) return;
        var box = el.getBoundingClientRect();
        var x = box.left + box.width * pos.x, y = box.top + box.height * pos.y;
        // What's under it (the cursor itself lets clicks through, so it's skipped).
        var under = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight ? document.elementFromPoint(x, y) : null;
        moveTo(x, y, under);
      }

      // Two links, the second one clicked, then a breather somewhere in the open.
      function plan() {
        var links = Array.prototype.filter.call(el.querySelectorAll(TARGETS), function (n) { return n !== el; });
        var box = el.getBoundingClientRect();
        var stops = [];
        for (var i = 0; i < 2 && links.length; i++) {
          var r = links[stop++ % links.length].getBoundingClientRect();
          stops.push({
            x: (r.left + r.width * (0.35 + Math.random() * 0.3) - box.left) / box.width,
            y: (r.top + r.height / 2 - box.top) / box.height,
            click: i === 1
          });
        }
        stops.push({ x: 0.15 + Math.random() * 0.7, y: 0.1 + Math.random() * 0.15 });
        wander = anime.createTimeline({
          autoplay: false,
          onComplete: function () { plan(); if (visible && !real) wander.play(); }
        });
        var at = 0;
        stops.forEach(function (s) {
          wander.add(pos, { x: s.x, y: s.y, duration: 1100, ease: 'inOut(3)', onUpdate: follow }, at);
          at += 1100;
          if (s.click) {
            wander.call(function () { press(true); }, at + 120);
            wander.call(function () { press(false); }, at + 280);
          }
          at += 650; // linger
        });
        wander.call(function () {}, at);
      }

      if (autoplay) {
        plan();
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (real) return;
          if (visible) wander.play(); else wander.pause();
        }).observe(el);
      }
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-custom-cursor]').forEach(function (el) {
        if (el._ccr) return;
        el._ccr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-custom-cursor>
  <a href="/work">Work</a>
  <img src="project.jpg" alt="Project" data-cursor="View">
</section>

<!-- or the whole page: -->
<body data-custom-cursor>