← Text effects

Proximity Weight

Letters near the pointer swell to a heavier weight and slim down again as it moves on, so a bold lens follows it along the line. With a variable font every step in between is smooth.

Feel the weight

How to use it

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

Options

Add any of these attributes to the same element.

data-minWeight of letters far from the pointer. Default 300.
data-maxWeight of the letter right under it. Default 900.
data-radiusHow far the swell reaches either side of the pointer, in em. Default 2.2.
data-colorColour the heaviest letters take.
data-autoplayA pretend pointer sweeps along the text while no real one is moving. Handy for demos and touch screens.

Heavier letters are wider, so the line grows a little around the pointer. With a regular (non-variable) font the weight can only jump between the weights you have loaded.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Proximity Weight: letters near the pointer swell to a heavier weight and
    // thin out again as it moves away, so a bold "lens" follows it along the
    // line. Smooth with a variable font (Inter, Roboto Flex, Recursive…).
    // Usage: <h1 data-proximity-weight>Feel the weight</h1>
    // Optional: data-min (weight far from the pointer; default 300), data-max
    // (weight right under it; default 900), data-radius (how far the swell
    // reaches either side, in em; default 2.2), data-color (colour the heaviest
    // letters take), data-autoplay (a pretend pointer sweeps along the text
    // while no real one is moving; handy for demos and touch screens).
    var DEFAULTS = { min: 300, max: 900, radius: 2.2 };

    var css =
      '.pxw-word{display:inline-block;white-space:nowrap}' +
      '.pxw-char{display:inline-block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    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 = 'pxw-word';
        word.setAttribute('aria-hidden', 'true');
        Array.from(w).forEach(function (c) {
          var s = document.createElement('span');
          s.className = 'pxw-char';
          s.textContent = c;
          s.style.fontWeight = min;
          word.appendChild(s);
          chars.push({ el: s, f: 0 });
        });
        el.appendChild(word);
      });
      return chars;
    }

    function setup(el) {
      var d = el.dataset;
      var min = +d.min || DEFAULTS.min;
      var max = +d.max || DEFAULTS.max;
      var radiusEm = +d.radius || DEFAULTS.radius;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var chars = split(el, min);
      var base = getComputedStyle(el).color;

      function paint(c) {
        c.el.style.fontWeight = Math.round(min + (max - min) * c.f);
        if (d.color) c.el.style.color = c.f > 0.02 ? 'color-mix(in srgb, ' + d.color + ' ' + Math.round(c.f * 100) + '%, ' + base + ')' : '';
      }

      if (reduced()) {
        // A still swell over the middle of the text.
        var mid = (chars.length - 1) / 2;
        chars.forEach(function (c, i) {
          var dist = Math.abs(i - mid) / Math.max(1, radiusEm * 1.6);
          c.f = dist < 1 ? (1 + Math.cos(Math.PI * dist)) / 2 : 0;
          paint(c);
        });
        return;
      }

      var pointer = null;        // screen coordinates, or null when away
      var lastReal = -1e9;
      var clock = 0;

      function onPointer(e) {
        pointer = { x: e.clientX, y: e.clientY };
        lastReal = performance.now();
      }
      window.addEventListener('pointermove', onPointer, { passive: true });
      window.addEventListener('pointerdown', onPointer, { passive: true });
      window.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; }, { passive: true });
      document.addEventListener('pointerleave', function () { pointer = null; });

      var prev = 0;
      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = prev ? Math.min(50, now - prev) : 16;
          prev = now;
          clock += dt;
          var fs = parseFloat(getComputedStyle(el).fontSize);
          var radius = radiusEm * fs;
          // Letter centres move as weights change, so read them every frame.
          chars.forEach(function (c) {
            var r = c.el.getBoundingClientRect();
            c.cx = r.left + r.width / 2;
            c.cy = r.top + r.height / 2;
          });
          var p = pointer;
          if (autoplay && now - lastReal > 2000 && chars.length) {
            var first = chars[0], last = chars[chars.length - 1];
            var k = (1 - Math.cos(clock * 0.0009)) / 2;
            p = { x: first.cx - radius * 0.5 + (last.cx - first.cx + radius) * k, y: (first.cy + last.cy) / 2 };
          }
          var ease = 1 - Math.pow(0.86, dt / 16);
          chars.forEach(function (c) {
            var target = 0;
            if (p) {
              var dx = c.cx - p.x, dy = (c.cy - p.y) * 0.8;
              var dist = Math.sqrt(dx * dx + dy * dy);
              target = dist < radius ? (1 + Math.cos(Math.PI * dist / radius)) / 2 : 0;
            }
            var f = c.f + (target - c.f) * ease;
            if (Math.abs(f - c.f) < 0.0005) return;
            c.f = f;
            paint(c);
          });
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
        else ticker.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-proximity-weight]').forEach(function (el) {
        if (el._pxw) return;
        el._pxw = true;
        setup(el);
      });
    }

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

<!-- from hairline to black, in purple: -->
<h1 data-proximity-weight data-min="100" data-max="900" data-color="#a855f7">Feel the weight</h1>