← Hover & cursor

Cursor Label

A small dot follows the pointer around. Over chosen items it swells into a circle with a word inside, like “View”, “Play” or “Drag”, then shrinks back to a dot when you move off.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-cursor-label to the section the dot should follow the pointer in.
  3. Give each item that should show a label a data-label, such as data-label="View".

Options

Add any of these attributes to the same element.

data-colorColour of the circle. Default #6a0dad.
data-text-colorColour of the label. Default white.
data-sizeWidth of the full circle, in px. Default 84.
data-hide-cursorHide the real cursor, on mouse and trackpad only.
data-autoplayA pretend pointer visits each labelled item while a real one isn’t in the section. Handy for demos and touch screens.

The circle sits over the section without catching clicks, so links underneath still work. Keep labels short: one word fits best. On touch screens a tap shows the label under your finger for a moment.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cursor Label: a small dot follows the pointer around an area. Over
    // chosen items it swells into a circle with a word inside, like "View" or
    // "Drag", then shrinks back to a dot when you move off.
    // Usage: <section data-cursor-label>
    //          <a href="/work/1" data-label="View">…</a>
    //          <div class="slider" data-label="Drag">…</div>
    //        </section>
    // Optional: data-color (circle colour, default #6a0dad), data-text-color
    // (label colour, default #fff), data-size (circle width, px, default 84),
    // data-hide-cursor (hide the real cursor, on mouse and trackpad only),
    // data-autoplay (a pretend pointer visits each labelled item while a real
    // one isn't in the area; handy for demos and touch screens).
    // The circle sits over the area without catching clicks.
    var DEFAULTS = { color: '#6a0dad', textColor: '#fff', size: 84 };

    var css =
      '.cul{position:relative}' +
      '.cul-layer{position:absolute;inset:0;z-index:5;pointer-events:none;overflow:hidden;border-radius:inherit}' +
      '.cul-dot{position:absolute;left:0;top:0;display:grid;place-items:center;border-radius:50%;opacity:0;' +
      'transform:translate(-50%,-50%) scale(.14);transition:transform .45s cubic-bezier(.2,.9,.3,1.2),opacity .3s;will-change:transform,left,top}' +
      '.cul-on .cul-dot{opacity:1}' +
      '.cul-dot.cul-big{transform:translate(-50%,-50%) scale(1)}' +
      '.cul-text{font:700 15px/1 system-ui,sans-serif;letter-spacing:-.01em;opacity:0;transform:scale(.6);transition:opacity .2s,transform .3s}' +
      '.cul-big .cul-text{opacity:1;transform:none;transition-delay:.08s}' +
      '@media (hover:hover) and (pointer:fine){.cul-nocursor,.cul-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; }

    function setup(el) {
      var d = el.dataset;
      var size = num(d.size, DEFAULTS.size);
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      el.classList.add('cul');
      if (d.hideCursor !== undefined && d.hideCursor !== 'false') el.classList.add('cul-nocursor');

      var layer = document.createElement('div');
      layer.className = 'cul-layer';
      layer.setAttribute('aria-hidden', 'true');
      var dot = document.createElement('span');
      dot.className = 'cul-dot';
      dot.style.width = dot.style.height = size + 'px';
      dot.style.background = d.color || DEFAULTS.color;
      dot.style.color = d.textColor || DEFAULTS.textColor;
      if (still) dot.style.transition = 'none';
      var text = document.createElement('span');
      text.className = 'cul-text';
      dot.appendChild(text);
      layer.appendChild(dot);
      el.appendChild(layer);

      var cur = { x: 0, y: 0 }, goal = { x: 0, y: 0 }, hover = false, hold = 0, visible = false, raf = 0, placed = false;
      var label = null, tour = -1, nextStop = 0, rest = false;

      function items() {
        return Array.prototype.filter.call(el.querySelectorAll('[data-label]'), function (t) { return t.offsetWidth; });
      }
      function setLabel(t) {
        if (t === label) return;
        label = t;
        if (t) text.textContent = t.getAttribute('data-label');
        dot.classList.toggle('cul-big', !!t);
      }
      function local(cx, cy) {
        var b = el.getBoundingClientRect();
        return { x: cx - b.left, y: cy - b.top };
      }
      function aim(cx, cy) {
        var p = local(cx, cy);
        goal.x = p.x; goal.y = p.y;
        if (!placed || still) { cur.x = p.x; cur.y = p.y; placed = true; }
        var hit = document.elementFromPoint(cx, cy);
        var t = hit && hit.closest ? hit.closest('[data-label]') : null;
        setLabel(t && el.contains(t) ? t : null);
        wake();
      }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        var touchHeld = Date.now() < hold;
        var fake = auto && !hover && !touchHeld;
        if (fake) {
          // Visit each labelled item, with a pass through open space between.
          var list = items(), b = el.getBoundingClientRect();
          if (!placed) { cur.x = goal.x = b.width / 2; cur.y = goal.y = b.height / 2; placed = true; }
          if (now >= nextStop && list.length) {
            tour = (tour + 1) % (list.length + 1);
            var t = list[tour];
            if (t) {
              var r = t.getBoundingClientRect();
              goal.x = r.left - b.left + r.width * (0.35 + Math.random() * 0.3);
              goal.y = r.top - b.top + r.height * (0.35 + Math.random() * 0.3);
            } else {
              goal.x = b.width * (0.2 + Math.random() * 0.6);
              goal.y = Math.random() < 0.5 ? b.height * 0.06 : b.height * 0.94;
            }
            nextStop = now + (t ? 1600 : 900);
            setLabel(null);
          }
          // The label pops up once the pretend pointer arrives.
          if (tour < list.length && Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) < 12) setLabel(list[tour]);
        }
        var lag = still ? 1 : 0.16;
        cur.x += (goal.x - cur.x) * lag;
        cur.y += (goal.y - cur.y) * lag;
        el.classList.toggle('cul-on', hover || touchHeld || fake || rest);
        dot.style.left = cur.x.toFixed(1) + 'px';
        dot.style.top = cur.y.toFixed(1) + 'px';
        if (!hover && !touchHeld && !fake && !rest) setLabel(null);
        var moving = Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) > 0.3;
        if (fake || moving || touchHeld) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        hover = false;
        wake();
      });
      // Touch: a tap shows the label where your finger lands for a moment.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 1600;
        placed = false;
        aim(e.clientX, e.clientY);
      });

      if (still && auto) {
        // Reduced motion: rest the label on the first item instead of touring.
        auto = false;
        var first = items()[0];
        if (first) {
          var fr = first.getBoundingClientRect();
          rest = true;
          aim(fr.left + fr.width / 2, fr.top + fr.height / 2);
          setLabel(first);
        }
      }

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-cursor-label]').forEach(function (el) {
        if (el._cul) return;
        el._cul = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-cursor-label data-hide-cursor>
  <a href="/work/harbour" data-label="View">…</a>
  <a href="/reel" data-label="Play">…</a>
  <div class="carousel" data-label="Drag">…</div>
</section>