← Buttons

Liquid Fill Button

Hover the button and liquid rises inside it, its surface rolling in two layers of waves, until it fills to the brim and the text turns white. Move away and it drains back down.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-liquid-fill-button to a button or link, ideally an outlined one.

Options

Add any of these attributes to the same element.

data-colorColour of the liquid. Default #6a0dad.
data-text-colorText colour once it’s full. Default white.
data-durationHow long it takes to fill. Default 900 ms.
data-autoplayIt fills and drains by itself every few seconds while in view. Handy for demos.

Keyboard focus fills it too, and on touch screens a tap fills it for a moment. The waves only roll while it’s on screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Liquid Fill Button: hover the button and liquid rises inside it, its
    // surface rolling in two layers of waves, until it fills to the brim and
    // the text turns white. Move away and it drains back down. Keyboard focus
    // fills it too, and a tap fills it on touch screens.
    // Usage: <button data-liquid-fill-button>Dive in</button>
    // Optional: data-color (the liquid, default #6a0dad), data-text-color (text
    // colour once full, default #fff), data-duration (ms to fill, default 900),
    // data-autoplay (it fills and drains by itself every few seconds while in
    // view; handy for demos).
    // With reduced motion it fills instantly and the waves keep still.
    var DEFAULTS = { color: '#6a0dad', text: '#fff', duration: 900, every: 3600 };
    var WAVE = 'M0 7Q12.5 0 25 7T50 7T75 7T100 7T125 7T150 7T175 7T200 7V14H0Z';

    var css =
      '.lfb{position:relative;overflow:hidden;isolation:isolate}' +
      '.lfb-liq{position:absolute;left:0;right:0;bottom:0;top:-12px;z-index:-1;pointer-events:none;transform:translateY(100%)}' +
      '.lfb-body{position:absolute;left:0;right:0;top:12px;bottom:0;background:var(--lfb-color)}' +
      '.lfb-wave{position:absolute;left:0;top:0;width:200%;max-width:none;height:13px;fill:var(--lfb-color);animation:lfb-roll 2.2s linear infinite;animation-play-state:paused}' +
      '.lfb-wave.lfb-back{opacity:.45;top:-3px;animation-duration:3.4s;animation-direction:reverse}' +
      '.lfb-live .lfb-wave{animation-play-state:running}' +
      '@keyframes lfb-roll{to{transform:translateX(-50%)}}' +
      '@media (prefers-reduced-motion:reduce){.lfb-wave{animation:none}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function wave(cls) {
      var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('class', 'lfb-wave' + cls);
      svg.setAttribute('viewBox', '0 0 200 14');
      svg.setAttribute('preserveAspectRatio', 'none');
      svg.innerHTML = '<path d="' + WAVE + '"/>';
      return svg;
    }

    function setup(el) {
      var d = el.dataset;
      var duration = reduced() ? 0 : +d.duration || DEFAULTS.duration;
      el.classList.add('lfb');
      el.style.setProperty('--lfb-color', d.color || DEFAULTS.color);

      var liq = document.createElement('span');
      liq.className = 'lfb-liq';
      liq.setAttribute('aria-hidden', 'true');
      var body = document.createElement('span');
      body.className = 'lfb-body';
      liq.appendChild(wave(' lfb-back'));
      liq.appendChild(wave(''));
      liq.appendChild(body);
      el.insertBefore(liq, el.firstChild);
      anime.utils.set(liq, { y: '100%' });
      var baseColor = getComputedStyle(el).color;

      var full = false;
      function fill(on) {
        if (on === full) return;
        full = on;
        anime.animate(liq, {
          y: on ? '0%' : '100%',
          duration: on ? duration : duration * 0.75,
          ease: on ? 'out(2)' : 'in(2)'
        });
        anime.animate(el, {
          color: on ? d.textColor || DEFAULTS.text : baseColor,
          duration: duration * 0.35,
          delay: on ? duration * 0.2 : 0,
          ease: 'out(2)',
          onComplete: function () { if (!full) el.style.color = ''; }
        });
      }
      function busy() { return el.matches(':hover') || document.activeElement === el; }

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') fill(true); });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch' && document.activeElement !== el) fill(false); });
      el.addEventListener('focus', function () { fill(true); });
      el.addEventListener('blur', function () { if (!el.matches(':hover')) fill(false); });
      var touchTimer = null;
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        clearTimeout(touchTimer);
        fill(true);
        touchTimer = setTimeout(function () { fill(false); }, 1600);
      });

      // The waves only roll while the button is on screen.
      var autoplay = !reduced() && d.autoplay !== undefined && d.autoplay !== 'false';
      var timer = null;
      var pass = function () {
        if (!busy()) {
          fill(true);
          setTimeout(function () { if (!busy()) fill(false); }, DEFAULTS.every * 0.55);
        }
        timer = setTimeout(pass, DEFAULTS.every);
      };
      new IntersectionObserver(function (entries) {
        var on = entries[0].isIntersecting;
        el.classList.toggle('lfb-live', on);
        clearTimeout(timer);
        if (on && autoplay) timer = setTimeout(pass, 500);
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a class="button" href="/start" data-liquid-fill-button>Dive in</a>

<!-- in teal: -->
<a class="button" href="/credit" data-liquid-fill-button data-color="#0891b2">Top up</a>