← Hover & cursor

Spotlight Hover

A soft pool of light follows the pointer across a card, or across a whole grid of cards, glowing through their translucent backgrounds and borders.

Fast
Light
Simple
Yours

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-spotlight-hover to a card, or to the container of a grid of cards.

Options

Add any of these attributes to the same element.

data-colorColour of the light. Default soft purple.
data-sizeRadius of the light, in px. Default 260.
data-autoplayDrift on its own while the pointer isn’t over it.

The light sits behind the content, so it works best on a dark section with cards that have a translucent background, e.g. rgba(255,255,255,.04).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spotlight Hover: a soft pool of light follows the pointer across a card,
    // or across a whole grid of cards, glowing through anything translucent.
    // Usage: <div class="card" data-spotlight-hover>…</div>
    //    or: <div class="grid" data-spotlight-hover>…cards…</div>
    // Optional: data-color (default rgba(168,85,247,.45)), data-size (radius in
    // px, default 260), data-autoplay (drift on its own while not hovered).
    // The light sits behind the element's content, so give cards inside a grid a
    // translucent background (e.g. rgba(255,255,255,.04)) to let it through.
    var DEFAULTS = { color: 'rgba(168, 85, 247, 0.45)', size: 260 };

    var css =
      '.sph{position:relative;isolation:isolate}' +
      '.sph>.sph-light{position:absolute;inset:0;z-index:-1;pointer-events:none;border-radius:inherit;opacity:0;' +
      'background:radial-gradient(circle var(--sph-size) at var(--sph-x) var(--sph-y),var(--sph-color),transparent 70%)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      el.classList.add('sph');
      var light = document.createElement('div');
      light.className = 'sph-light';
      light.setAttribute('aria-hidden', 'true');
      light.style.setProperty('--sph-color', d.color || DEFAULTS.color);
      light.style.setProperty('--sph-size', (+d.size || DEFAULTS.size) + 'px');
      el.insertBefore(light, el.firstChild);
      var pos = { x: el.clientWidth / 2, y: el.clientHeight / 2 };
      var apply = function () {
        light.style.setProperty('--sph-x', pos.x + 'px');
        light.style.setProperty('--sph-y', pos.y + 'px');
      };
      apply();
      var hovering = false;

      // Glide towards the pointer rather than snapping, so it feels like light.
      function moveTo(x, y, duration) {
        anime.animate(pos, { x: x, y: y, duration: duration, ease: 'out(3)', onUpdate: apply });
      }
      function fade(to) { anime.animate(light, { opacity: to, duration: 400, ease: 'out(2)' }); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hovering = true;
        var r = el.getBoundingClientRect();
        pos.x = e.clientX - r.left;
        pos.y = e.clientY - r.top;
        apply();
        fade(1);
      });
      el.addEventListener('pointermove', function (e) {
        if (!hovering) return;
        var r = el.getBoundingClientRect();
        moveTo(e.clientX - r.left, e.clientY - r.top, 450);
      });
      el.addEventListener('pointerleave', function () {
        hovering = false;
        if (d.autoplay === undefined) fade(0);
      });

      if (d.autoplay !== undefined && d.autoplay !== 'false') {
        fade(1);
        // A slow figure-of-eight drift while nobody is hovering.
        var t = 0;
        var timer = anime.createTimer({
          loop: true,
          duration: 1000,
          onUpdate: function () {
            if (hovering) return;
            t += 0.012;
            var w = el.clientWidth, h = el.clientHeight;
            pos.x = w * (0.5 + 0.38 * Math.sin(t));
            pos.y = h * (0.5 + 0.32 * Math.sin(t * 2));
            apply();
          }
        });
        new IntersectionObserver(function (entries) {
          if (entries[0].isIntersecting) timer.play(); else timer.pause();
        }).observe(el);
      }
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="grid" data-spotlight-hover>
  <div class="card">…</div>
  <div class="card">…</div>
  <div class="card">…</div>
</div>