← Text reveals

Liquid Text Fill

Hollow, outlined letters fill up with colour from the bottom, with a wavy surface sloshing across the top, like a glass being filled. Loop it and the liquid drains away before filling again.

Fill me up

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-liquid-text-fill to a heading or short line of text. The outline takes the element’s own text colour.

Options

Add any of these attributes to the same element.

data-colorThe liquid’s colour. Default #8b5cf6.
data-waveHow tall the waves are, as a fraction of the font size. Default 0.07.
data-loopFill, hold, drain and fill again.
data-holdHow long it stays full before draining, when looping. Default 1600 ms.
data-durationTime to fill to the top. Default 2600 ms.
data-delayWait before starting. Default 0 ms.

The waves pause while the text is off screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Liquid Text Fill: outlined text fills with colour from the bottom up, with a
    // wavy surface sloshing on top, like a glass filling up.
    // Usage: <h1 data-liquid-text-fill>Fill me up</h1>
    // Optional: data-color (the liquid, default #8b5cf6), data-loop (fill, hold,
    // drain and fill again), data-hold (ms to stay full before draining),
    // data-duration (ms to fill), data-wave (wave height as a fraction of the
    // font size, default 0.07), data-delay (ms before starting).
    // The outline uses the element's own text colour.
    var DEFAULTS = { color: '#8b5cf6', duration: 2600, hold: 1600, wave: 0.07, delay: 0 };

    var css =
      '.ltf-on{position:relative;color:transparent!important}' +
      '.ltf-fill{position:absolute;inset:0;pointer-events:none;-webkit-text-stroke:0;' +
      'clip-path:inset(100% 0 0 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 cs = getComputedStyle(el);
      var fs = parseFloat(cs.fontSize);
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var color = d.color || DEFAULTS.color;

      el.setAttribute('aria-label', text);
      el.textContent = '';
      // The outline: the element's own text, hollowed out.
      var line = document.createElement('span');
      line.setAttribute('aria-hidden', 'true');
      line.textContent = text;
      line.style.webkitTextStroke = Math.max(1.5, fs * 0.022).toFixed(2) + 'px ' + cs.color;
      el.appendChild(line);
      el.classList.add('ltf-on');

      // Two copies of the liquid: a paler back wave and the main one in front.
      var layers = [0.45, 1].map(function (alpha) {
        var f = document.createElement('span');
        f.className = 'ltf-fill';
        f.setAttribute('aria-hidden', 'true');
        f.textContent = text;
        f.style.color = color;
        f.style.opacity = alpha;
        f.style.padding = cs.padding;
        el.appendChild(f);
        return f;
      });

      return { layers: layers, level: 0, amp: fs * (+d.wave || DEFAULTS.wave), t: 0 };
    }

    // A polygon whose top edge is a sine wave at the current level.
    function wave(w, h, level, amp, phase, lift) {
      var y0 = (h + amp * 2) * (1 - level) - amp - lift;
      var pts = [], n = 24;
      for (var i = 0; i <= n; i++) {
        var x = (w * i) / n;
        var y = y0 + amp * Math.sin((x / w) * Math.PI * 2.4 + phase) +
          amp * 0.35 * Math.sin((x / w) * Math.PI * 5.3 - phase * 1.7);
        pts.push(x.toFixed(1) + 'px ' + y.toFixed(1) + 'px');
      }
      pts.push(w + 'px ' + (h + 2) + 'px', '0px ' + (h + 2) + 'px');
      return 'polygon(' + pts.join(',') + ')';
    }

    function render(el, s) {
      var w = el.offsetWidth, h = el.offsetHeight;
      // Calm the surface as the glass gets full or empty.
      var amp = s.amp * (0.35 + 0.65 * Math.sin(Math.PI * Math.min(1, Math.max(0, s.level))));
      s.layers[0].style.clipPath = wave(w, h, s.level, amp, s.t * 0.0031 + 2.2, amp * 0.6);
      s.layers[1].style.clipPath = wave(w, h, s.level, amp, -s.t * 0.0024, 0);
    }

    function play(el, s) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var last = 0;

      function frame(now) {
        if (last) s.t += now - last;
        last = now;
        if (s.visible) render(el, s);
        if (s.visible && (loop || s.level < 1.05)) requestAnimationFrame(frame);
        else last = 0;
      }
      s.kick = function () { requestAnimationFrame(frame); };
      s.kick();

      var tl = anime.createTimeline({ loop: loop, loopDelay: 500, delay: +d.delay || DEFAULTS.delay });
      tl.add(s, { level: [0, 1.06], duration: duration, ease: 'inOut(1.6)' });
      if (loop) {
        tl.add(s, { level: [1.06, 0], duration: duration * 0.55, ease: 'inOut(2)' },
          '+=' + (+d.hold || DEFAULTS.hold));
      }
    }

    function init() {
      // Watches each element for as long as it lives: starts it the first time
      // it scrolls into view and pauses the waves while it's off screen.
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._ltf;
          var was = s.visible;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.started) { s.started = true; play(el, s); }
          else if (!was && s.kick) s.kick();
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-liquid-text-fill]').forEach(function (el) {
        if (el._ltf) return;
        var s = setup(el);
        el._ltf = s;
        if (reduced()) {
          s.layers.forEach(function (f) { f.style.clipPath = 'none'; });
          return;
        }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-liquid-text-fill>Fill me up</h1>

<!-- or, with pink liquid, looping: -->
<h1 data-liquid-text-fill data-color="#ec4899" data-loop>Fill me up</h1>