← Hover & cursor

Border Glow Follow

A card’s border lights up in a two-colour glow right where the pointer is, with a soft bloom, and the light slides round the edge as the pointer moves. On a grid, neighbouring cards catch the light as the pointer nears them.

Starter$0/moFor side projects
Pro$12/moFor growing teams
Studio$29/moFor agencies

How to use it

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

Options

Add any of these attributes to the same element.

data-groupLight up the element’s children instead of the element itself.
data-colorsTwo colours for the light. Default #c084fc,#22d3ee.
data-sizeRadius of the light, in px. Default 180.
data-widthWidth of the glowing border, in px. Default the card’s own border, or 2.
data-autoplayThe light roams on its own while the pointer isn’t near. Handy for demos and touch screens.

The glow draws over each card’s own border, so give cards a quiet border and rounded corners. It looks best on a dark section.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Border Glow Follow: a card's border lights up where the pointer is, with a
    // soft bloom, and the light slides round the edge as the pointer moves. On a
    // grid, the borders of neighbouring cards catch the light too.
    // Usage: <div class="card" data-border-glow-follow>…</div>
    //    or: <div class="grid" data-border-glow-follow data-group>…cards…</div>
    // Optional: data-group (light up the element's children instead of the
    // element itself), data-colors (two colours for the light, default
    // #c084fc,#22d3ee), data-size (radius of the light in px, default 180),
    // data-width (glowing border width in px, default the card's own border or
    // 2), data-autoplay (the light roams on its own while the pointer isn't
    // near; handy for demos and touch screens).
    // Cards keep their own border and background: the glow draws over the border.
    var DEFAULTS = { colors: '#c084fc,#22d3ee', size: 180 };

    var css =
      '.bgf-card{position:relative}' +
      '.bgf-edge, .bgf-bloom{position:absolute;pointer-events:none;border-radius:inherit;opacity:var(--bgf-o,0)}' +
      '.bgf-edge{inset:calc(-1 * var(--bgf-bw));padding:var(--bgf-w);' +
      'background:radial-gradient(circle var(--bgf-size) at var(--bgf-x) var(--bgf-y),var(--bgf-c1),var(--bgf-c2) 45%,transparent 75%);' +
      '-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;' +
      'mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask-composite:exclude}' +
      '.bgf-bloom{inset:0;filter:blur(8px)}' +
      '.bgf-bloom .bgf-edge{--bgf-w:5px;opacity:1}' +
      '.bgf-inner{position:absolute;inset:0;pointer-events:none;border-radius:inherit;opacity:var(--bgf-o,0);' +
      'background:radial-gradient(circle var(--bgf-size) at var(--bgf-x) var(--bgf-y),color-mix(in srgb,var(--bgf-c1) 14%,transparent),transparent 70%)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function decorate(card, o) {
      var cs = getComputedStyle(card);
      if (cs.position === 'static') card.classList.add('bgf-card');
      var bw = parseFloat(cs.borderTopWidth) || 0;
      card.style.setProperty('--bgf-bw', bw + 'px');
      card.style.setProperty('--bgf-w', (o.width || Math.max(bw, 2)) + 'px');
      card.style.setProperty('--bgf-size', o.size + 'px');
      card.style.setProperty('--bgf-c1', o.colors[0]);
      card.style.setProperty('--bgf-c2', o.colors[1] || o.colors[0]);
      var inner = document.createElement('div');
      inner.className = 'bgf-inner';
      var bloom = document.createElement('div');
      bloom.className = 'bgf-bloom';
      bloom.innerHTML = '<div class="bgf-edge"></div>';
      var edge = document.createElement('div');
      edge.className = 'bgf-edge';
      [inner, bloom, edge].forEach(function (n) { n.setAttribute('aria-hidden', 'true'); card.appendChild(n); });
      return { el: card, o: 0 };
    }

    function setup(el) {
      var d = el.dataset;
      var o = {
        colors: (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); }),
        size: +d.size || DEFAULTS.size,
        width: +d.width || 0
      };
      var group = d.group !== undefined && d.group !== 'false';
      var cards = (group ? Array.prototype.slice.call(el.children) : [el]).map(function (c) { return decorate(c, o); });
      // The light glides after the pointer (screen coordinates).
      var light = { x: 0, y: 0 }, aim = { x: 0, y: 0 };
      var real = false, near = false, visible = false, placed = false;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var t = Math.random() * 10;

      function paint() {
        cards.forEach(function (c) {
          var r = c.el.getBoundingClientRect();
          // Full strength inside the card, fading out over the light's radius.
          var dx = Math.max(r.left - light.x, 0, light.x - r.right);
          var dy = Math.max(r.top - light.y, 0, light.y - r.bottom);
          var want = (real || autoplay) ? Math.max(0, 1 - Math.hypot(dx, dy) / (o.size * 0.75)) : 0;
          c.o += (want - c.o) * 0.18;
          c.el.style.setProperty('--bgf-x', (light.x - r.left).toFixed(1) + 'px');
          c.el.style.setProperty('--bgf-y', (light.y - r.top).toFixed(1) + 'px');
          c.el.style.setProperty('--bgf-o', c.o.toFixed(3));
        });
      }

      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          if (!real && autoplay) {
            // A slow wander back and forth across the cards.
            t += 0.011;
            var b = el.getBoundingClientRect();
            aim.x = b.left + b.width * (0.5 + 0.44 * Math.sin(t * 0.9));
            aim.y = b.top + b.height * (0.5 + 0.3 * Math.sin(t * 1.6 + 1));
          }
          if (!placed) { light.x = aim.x; light.y = aim.y; placed = true; }
          light.x += (aim.x - light.x) * 0.16;
          light.y += (aim.y - light.y) * 0.16;
          paint();
          // Nothing left to draw: rest until the pointer comes back.
          if (!real && !autoplay && cards.every(function (c) { return c.o < 0.005; })) ticker.pause();
        }
      });

      function wake() { if (visible || !autoplay) ticker.play(); }

      // Track the pointer across the page, so cards light up as it nears them.
      document.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') return;
        var b = el.getBoundingClientRect();
        var reach = o.size * 0.75;
        near = e.clientX > b.left - reach && e.clientX < b.right + reach && e.clientY > b.top - reach && e.clientY < b.bottom + reach;
        if (!near) { if (real) { real = false; if (!autoplay) wake(); } return; }
        if (!real && !autoplay) placed = false;
        real = true;
        aim.x = e.clientX;
        aim.y = e.clientY;
        wake();
      }, { passive: true });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && autoplay) ticker.play();
        else if (!visible) ticker.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="pricing" data-border-glow-follow data-group>
  <div class="card">…</div>
  <div class="card">…</div>
  <div class="card">…</div>
</div>