← Text effects

Variable-weight Wave

A swell of boldness rolls through the letters: each one thickens as the crest passes, then thins out again behind it. It bends the weight axis of a variable font, so the strokes really grow rather than just scaling.

Weight for it

How to use it

  1. Paste the snippet just before </head>.
  2. Use a variable font with a weight axis, such as Inter, Roboto Flex or Recursive.
  3. Add data-variable-weight-wave to a heading.

Options

Add any of these attributes to the same element.

data-minThe thinnest weight. Default 200.
data-maxThe boldest weight, at the crest. Default 900.
data-speedTime for one swell. Default 1600 ms.
data-staggerGap between letters: smaller is a longer wave. Default 90 ms.
data-colorColour letters turn as the crest passes.

With a regular (non-variable) font the weight can only jump between the weights you have loaded. Bolder letters are wider, so centred text gently breathes as the wave passes.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Variable-weight Wave: a swell of boldness rolls through the letters, each
    // one thickening as the crest passes and thinning out again behind it.
    // Needs a variable font with a weight axis (Inter, Roboto Flex, Recursive…).
    // Usage: <h1 data-variable-weight-wave>Heavy lifting</h1>
    // Optional: data-min (thinnest weight, default 200), data-max (boldest
    // weight, default 900), data-speed (ms for one swell, default 1600),
    // data-stagger (ms between letters, default 90), data-color (letters take
    // this colour at the crest).
    var DEFAULTS = { min: 200, max: 900, speed: 1600, stagger: 90 };

    var css =
      '.vww-word{display:inline-block;white-space:nowrap}' +
      ".vww-char{display:inline-block;font-variation-settings:'wght' var(--vww-w)}";
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function split(el, min) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var chars = [];
      text.split(' ').forEach(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'vww-word';
        word.setAttribute('aria-hidden', 'true');
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'vww-char';
          s.textContent = c;
          s.style.setProperty('--vww-w', min);
          word.appendChild(s);
          chars.push(s);
        });
        el.appendChild(word);
      });
      return chars;
    }

    function start(el, chars, min, max) {
      var d = el.dataset;
      var props = {
        '--vww-w': [min, max, min],
        duration: +d.speed || DEFAULTS.speed,
        ease: 'inOutSine',
        loop: true,
        // Each letter swells a little later, so the bold crest rolls along the line.
        delay: anime.stagger(+d.stagger || DEFAULTS.stagger)
      };
      if (d.color) {
        var base = getComputedStyle(el).color;
        props.color = [base, d.color, base];
      }
      return anime.animate(chars, props);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-variable-weight-wave]').forEach(function (el) {
        if (el._vww) return;
        el._vww = true;
        // Reduced motion: the text keeps its own weight and stays still.
        if (reduced) return;
        var d = el.dataset;
        var min = +d.min || DEFAULTS.min;
        var max = +d.max || DEFAULTS.max;
        var chars = split(el, min);
        var anim = null;
        // Only roll while visible, to save battery.
        new IntersectionObserver(function (entries) {
          if (!entries[0].isIntersecting) { if (anim) anim.pause(); return; }
          if (anim) anim.play();
          else anim = start(el, chars, min, max);
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-variable-weight-wave>Heavy lifting</h1>

<!-- a subtler wave that turns purple at the crest: -->
<h1 data-variable-weight-wave data-min="400" data-max="800" data-color="#a855f7">Heavy lifting</h1>