← Buttons

Pulsing Call-to-action

Soft rings ripple out from the button one after another, fading as they grow, while the button swells gently with each pulse. It draws the eye without shouting, and pauses while someone hovers it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pulsing-call-to-action to the one button you most want people to press.

Options

Add any of these attributes to the same element.

data-colorColour of the rings. Default: the button’s background colour.
data-ringsRings per pulse, 1 to 4. Default 2.
data-sizeHow far the rings travel, in pixels. Default 24.
data-intervalTime between pulses. Default 2200 ms.

It only pulses while on screen, and keeps still with reduced motion. Use it on one button per page at most.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pulsing Call-to-action: soft rings ripple out from the button, one after
    // another, fading as they grow, while the button itself swells gently with
    // each pulse, to draw the eye. Hover or focus it and the pulsing pauses, so
    // it stays calm while someone is about to click.
    // Usage: <a class="button" href="/signup" data-pulsing-call-to-action>Start free trial</a>
    // Optional: data-color (ring colour; default the button's background
    // colour), data-rings (rings per pulse, default 2), data-size (how far the
    // rings travel in px, default 24), data-interval (ms between pulses,
    // default 2200).
    // It only pulses while on screen, and keeps still with reduced motion.
    var DEFAULTS = { rings: 2, size: 24, interval: 2200 };

    var css =
      '.pcta{isolation:isolate}' +
      '.pcta-ring{position:absolute;inset:0;z-index:-1;border-radius:inherit;border:2px solid var(--pcta-color);' +
      'background:color-mix(in srgb,var(--pcta-color) 14%,transparent);opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var count = Math.max(1, Math.min(4, +d.rings || DEFAULTS.rings));
      var size = +d.size || DEFAULTS.size;
      var every = +d.interval || DEFAULTS.interval;
      var cs = getComputedStyle(el);
      var color = d.color || (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' ? cs.backgroundColor : '#6a0dad');
      el.classList.add('pcta');
      if (cs.position === 'static') el.style.position = 'relative';
      el.style.setProperty('--pcta-color', color);

      var rings = [];
      for (var i = 0; i < count; i++) {
        var r = document.createElement('span');
        r.className = 'pcta-ring';
        r.setAttribute('aria-hidden', 'true');
        el.insertBefore(r, el.firstChild);
        rings.push(r);
      }

      function pulse() {
        // The rings grow by moving their edges outwards, so the gap is the same
        // all round whatever the button's shape.
        anime.animate(rings, {
          top: [0, -size], right: [0, -size], bottom: [0, -size], left: [0, -size],
          opacity: [{ to: 0.85, duration: 120, ease: 'out(2)' }, { to: 0, duration: 1480, ease: 'in(1.6)' }],
          borderWidth: [2.5, 1],
          duration: 1600,
          delay: anime.stagger(340),
          ease: 'out(2.4)'
        });
        anime.animate(el, {
          scale: [{ to: 1.045, duration: 260, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'inOut(2)' }]
        });
      }

      var timer = null, seen = false;
      function calm() { return el.matches(':hover') || document.activeElement === el; }
      var tick = function () {
        if (!calm()) pulse();
        timer = setTimeout(tick, every);
      };
      new IntersectionObserver(function (entries) {
        seen = entries[0].isIntersecting;
        clearTimeout(timer);
        if (seen) timer = setTimeout(tick, 400);
      }).observe(el);
    }

    function init() {
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      document.querySelectorAll('[data-pulsing-call-to-action]').forEach(function (el) {
        if (el._pcta) return;
        el._pcta = true;
        setup(el);
      });
    }

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

<!-- three rings, a little calmer: -->
<a class="button" href="/signup" data-pulsing-call-to-action data-rings="3" data-interval="3000">Start free trial</a>