← Buttons

Gradient Border Spin

A band of colour travels round and round the button’s border, with a soft glow of the same colours spilling out behind it. Hover it and it surges faster and glows brighter.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-gradient-border-spin to a button or link with a solid background.

Options

Add any of these attributes to the same element.

data-colorsComma-separated colours for the band. Default #a855f7,#ec4899,#f59e0b.
data-widthBorder width in pixels. Default 2.
data-speedTime for one lap. Default 3000 ms.
data-glow="false"Turn off the glow.

It only spins while it’s on screen. It looks best on a dark button on a dark background.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Gradient Border Spin: a band of colour travels round and round the
    // button's border, with a soft glow of the same colours spilling out behind
    // it. Hover or focus the button and it speeds up and glows brighter. It
    // spins while it's on screen.
    // Usage: <button data-gradient-border-spin>Start free trial</button>
    // Optional: data-colors (comma-separated, default "#a855f7,#ec4899,#f59e0b"),
    // data-width (border width in px, default 2), data-speed (ms per lap,
    // default 3000), data-glow="false" (no glow).
    // The button keeps its own background; give it a solid one.
    var DEFAULTS = { colors: '#a855f7,#ec4899,#f59e0b', width: 2, speed: 3000 };

    // The layers are cut out with masks so they never cover the button: the
    // ring keeps only the border band, the glow only what's outside the button.
    var cut = 'linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)';
    var cutWebkit = 'linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0)';
    var css =
      '.gbs{position:relative;isolation:isolate}' +
      '.gbs-ring,.gbs-glow i{position:absolute;inset:calc(var(--gbs-w) * -1);padding:var(--gbs-w);pointer-events:none;' +
      'background:conic-gradient(from var(--gbs-a,0deg),var(--gbs-colors));' +
      '-webkit-mask:' + cutWebkit + ';-webkit-mask-composite:xor;mask:' + cut + '}' +
      // The glow: a blurred copy of the ring (blurred as a whole, in the middle
      // layer), with everything over the button masked away again.
      '.gbs-glow{position:absolute;inset:-20px;padding:20px;pointer-events:none;z-index:-1;opacity:var(--gbs-glow,.6);' +
      '-webkit-mask:' + cutWebkit + ';-webkit-mask-composite:xor;mask:' + cut + '}' +
      '.gbs-glow b{position:absolute;inset:20px;filter:blur(9px)}' +
      '.gbs-glow i{padding:calc(var(--gbs-w) * 2.5)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      // The gradient ends where it started, so there's no seam.
      colors.push(colors[0]);
      el.classList.add('gbs');
      el.style.setProperty('--gbs-colors', colors.join(','));
      el.style.setProperty('--gbs-w', (+d.width || DEFAULTS.width) + 'px');
      // The glow sits outside the button, so match its rounding to the ring's.
      var radius = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
      var ringRadius = radius ? radius + (+d.width || DEFAULTS.width) + 'px' : '0';
      var ring = document.createElement('span');
      ring.className = 'gbs-ring';
      ring.setAttribute('aria-hidden', 'true');
      ring.style.borderRadius = ringRadius;
      el.appendChild(ring);
      var glow = null;
      if (d.glow !== 'false') {
        glow = document.createElement('span');
        glow.className = 'gbs-glow';
        glow.setAttribute('aria-hidden', 'true');
        glow.style.borderRadius = radius ? radius + 20 + 'px' : '0';
        glow.innerHTML = '<b><i style="border-radius:' + ringRadius + '"></i></b>';
        el.appendChild(glow);
      }
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      var spin = anime.animate(el, {
        '--gbs-a': ['0deg', '360deg'],
        duration: +d.speed || DEFAULTS.speed,
        ease: 'linear',
        loop: true,
        autoplay: false
      });

      var hot = false;
      function heat(on) {
        if (hot === on) return;
        hot = on;
        // Ease the playback speed rather than jumping, so the band surges.
        anime.animate(spin, { speed: on ? 3 : 1, duration: on ? 400 : 900, ease: 'out(2)' });
        if (glow) anime.animate(el, { '--gbs-glow': on ? 1 : 0.6, duration: 400, ease: 'out(2)' });
      }
      el.addEventListener('pointerenter', function () { heat(true); });
      el.addEventListener('pointerleave', function () { heat(document.activeElement === el); });
      el.addEventListener('focus', function () { heat(true); });
      el.addEventListener('blur', function () { heat(el.matches(':hover')); });

      // Only spin while visible, to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) spin.play();
        else spin.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-gradient-border-spin]').forEach(function (el) {
        if (el._gbs) return;
        el._gbs = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a class="button" href="/signup" data-gradient-border-spin>Start free trial</a>

<!-- thicker, in your colours: -->
<a class="button" href="/beta" data-gradient-border-spin data-colors="#22d3ee,#a3e635" data-width="3">Join the beta</a>