← Buttons

Pointer Fill Button

Hover the button and a circle of colour grows from the exact spot where the pointer came in until it floods the whole button, the text turning white to stay readable. Move away and the colour shrinks back into the spot where the pointer left. On a phone it floods out from your finger.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pointer-fill-button to a button. Style the button as normal; the fill goes behind its text.

Options

Add any of these attributes to the same element.

data-colorColour of the fill. Default #6a0dad.
data-text-colorText colour over the fill. Default #fff.
data-durationHow long the fill takes to spread. Default 550 ms.
data-autoplayA pretend pointer wanders in and out from different spots while in view, until someone uses it. Handy for demos.

Keyboard focus fills it from the centre. With reduced motion the colour swaps instantly.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pointer Fill Button: hover the button and a circle of colour grows from
    // the exact spot where the pointer came in until it floods the button,
    // with the text changing colour to stay readable. Move away and the
    // colour shrinks back to the spot where the pointer left. On touch
    // screens it floods from your finger and drains a moment later.
    // Usage: <button data-pointer-fill-button>Get started</button>
    // Optional: data-color (the fill, default #6a0dad), data-text-color (text
    // colour over the fill, default #fff), data-duration (ms, default 550),
    // data-autoplay (a pretend pointer wanders in and out from different
    // spots while in view, until someone uses it; handy for demos).
    // Keyboard focus fills it from the centre. With reduced motion the
    // colour swaps instantly.
    var DEFAULTS = { color: '#6a0dad', text: '#fff', duration: 550 };

    var css =
      '.pfb{position:relative;overflow:hidden;isolation:isolate;-webkit-tap-highlight-color:transparent}' +
      '.pfb-fill{position:absolute;left:0;top:0;z-index:-1;border-radius:50%;pointer-events:none;background:var(--pfb-c);transform:scale(0)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      el.classList.add('pfb');
      el.style.setProperty('--pfb-c', d.color || DEFAULTS.color);
      var fill = document.createElement('span');
      fill.className = 'pfb-fill';
      fill.setAttribute('aria-hidden', 'true');
      el.insertBefore(fill, el.firstChild);
      var baseColor = getComputedStyle(el).color;
      var filled = false;

      // Place the circle at (x, y) inside the button, big enough to reach the far corner.
      function place(x, y) {
        var w = el.offsetWidth, h = el.offsetHeight;
        var r = Math.sqrt(Math.pow(Math.max(x, w - x), 2) + Math.pow(Math.max(y, h - y), 2)) + 2;
        fill.style.width = fill.style.height = r * 2 + 'px';
        fill.style.left = x - r + 'px';
        fill.style.top = y - r + 'px';
      }

      function local(e) {
        var r = el.getBoundingClientRect();
        // Account for any scale on the button (its offset sizes are unscaled).
        var sx = r.width / el.offsetWidth || 1, sy = r.height / el.offsetHeight || 1;
        return { x: (e.clientX - r.left) / sx, y: (e.clientY - r.top) / sy };
      }

      function into(x, y) {
        if (filled) return;
        filled = true;
        var t = reduced() ? 0 : duration;
        place(x, y);
        anime.animate(fill, { scale: [0, 1], duration: t, ease: 'out(3)' });
        anime.animate(el, { color: d.textColor || DEFAULTS.text, duration: t * 0.5, ease: 'out(2)' });
      }

      function out(x, y) {
        if (!filled) return;
        filled = false;
        var t = reduced() ? 0 : duration * 0.85;
        // Re-centre the full circle on the exit point, still covering the button, then shrink.
        var cur = parseFloat(anime.utils.get(fill, 'scale')) || 1;
        place(x, y);
        anime.utils.set(fill, { scale: cur < 1 ? cur : 1 });
        anime.animate(fill, { scale: 0, duration: t, ease: 'inOut(2)' });
        anime.animate(el, { color: baseColor, duration: t * 0.6, delay: t * 0.35, ease: 'out(2)' });
      }

      var userTouched = false;
      var touchTimer = null;
      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        userTouched = true;
        var p = local(e);
        into(p.x, p.y);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        var p = local(e);
        out(p.x, p.y);
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        userTouched = true;
        var p = local(e);
        clearTimeout(touchTimer);
        if (!filled) into(p.x, p.y);
        touchTimer = setTimeout(function () { out(p.x, p.y); }, 1100);
      });
      el.addEventListener('focus', function () {
        if (el.matches(':focus-visible')) into(el.offsetWidth / 2, el.offsetHeight / 2);
      });
      el.addEventListener('blur', function () {
        if (!el.matches(':hover')) out(el.offsetWidth / 2, el.offsetHeight / 2);
      });

      if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
        // A pretend pointer: enters from a random point on one edge, leaves by another.
        var edgePoint = function () {
          var w = el.offsetWidth, h = el.offsetHeight, s = Math.floor(Math.random() * 4), f = 0.15 + Math.random() * 0.7;
          if (s === 0) return { x: f * w, y: 0 };
          if (s === 1) return { x: w, y: f * h };
          if (s === 2) return { x: f * w, y: h };
          return { x: 0, y: f * h };
        };
        var timer = null;
        var loop = function () {
          if (userTouched) return;
          var p = edgePoint();
          if (filled) out(p.x, p.y);
          else into(p.x, p.y);
          timer = setTimeout(loop, filled ? 1500 : 1100);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(loop, 600);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-pointer-fill-button]').forEach(function (el) {
        if (el._pfb) return;
        el._pfb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-pointer-fill-button>Get started</button>

<!-- pink, a little slower: -->
<button data-pointer-fill-button data-color="#db2777" data-duration="800">Hover me</button>