Variable Font Weight Hover

Each character in a heading responds to pointer proximity by smoothly adjusting its variable font weight. Characters closest to the cursor become heaviest; those farther away fade back to the minimum weight.

GSAPVariable FontHoverInteractiveTypography

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>

Code

index.html
html
<h1 data-font-weight-hover data-radius="400" data-min="200" data-max="1000" class="font-weight__heading">
  Looooook at this!<br>It&#x27;s so smooth.
</h1>
styles.css
css
.font-weight__heading {
  font-variation-settings: "wght" 540;
  letter-spacing: -.02em;
  margin-top: 0;
  margin-bottom: 0;
  font-family: Haffer VF, Arial, sans-serif;
  font-size: clamp(2em, 6vw, 8em);
  line-height: 1;
}
script.js
javascript
function initVariableFontWeightHover() {
  const isTouch     = window.matchMedia("(hover: none), (pointer: coarse)").matches;
  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (isTouch || reduceMotion) return;

  const targets = document.querySelectorAll("[data-font-weight-hover]");
  if (!targets.length) return;

  const rangeDefault = 500;
  const mouse = { x: 0, y: 0 };
  let hasPointer = false;
  let isActive   = false;
  const chars    = [];

  function clamp(v, min, max) { return v < min ? min : v > max ? max : v; }

  function numAttr(el, key, fallback) {
    const v = parseFloat(el.dataset[key]);
    return Number.isFinite(v) ? v : fallback;
  }

  function readFontWeight(el) {
    const fw = getComputedStyle(el).fontWeight;
    const parsed = parseFloat(fw);
    if (Number.isFinite(parsed)) return parsed;
    if (fw === "bold") return 700;
    return 400;
  }

  function weightFromDistance(dist, minw, maxw, range) {
    if (dist >= range) return minw;
    const t = 1 - dist / range;
    return minw + (maxw - minw) * t;
  }

  function calculatePositions() {
    for (let i = 0; i < chars.length; i++) {
      const r = chars[i].el.getBoundingClientRect();
      chars[i].cx = r.left + r.width  / 2 + window.scrollX;
      chars[i].cy = r.top  + r.height / 2 + window.scrollY;
    }
  }

  function splitChars(el) {
    if (el.dataset.fontWeightHoverInit === "true") return null;
    el.dataset.fontWeightHoverInit = "true";
    el.fontWeightHoverSplit = el.fontWeightHoverSplit || new SplitText(el, { type: "chars,words", charsClass: "char" });
    return el.fontWeightHoverSplit.chars || [];
  }

  function activate() {
    if (isActive) return;
    isActive = true;
    for (let i = 0; i < chars.length; i++) {
      const d = chars[i];
      d.el.style.setProperty("--wght", d.startw);
      d.el.style.fontVariationSettings = "'wght' var(--wght)";
    }
    calculatePositions();
  }

  targets.forEach(el => {
    const minw  = numAttr(el, "min",   300);
    const maxw  = numAttr(el, "max",   900);
    const range = numAttr(el, "range", rangeDefault);

    const split = splitChars(el);
    if (!split) return;

    split.forEach(ch => {
      const startw = readFontWeight(ch);
      chars.push({
        el: ch,
        cx: 0, cy: 0,
        startw, minw, maxw, range,
        setw: gsap.quickTo(ch, "--wght", { duration: 0.4, ease: "power2.out", overwrite: "auto" }),
      });
    });
  });

  window.addEventListener("pointermove", (e) => {
    hasPointer = true;
    mouse.x = e.pageX;
    mouse.y = e.pageY;
    if (!isActive) activate();
  }, { passive: true });

  window.addEventListener("resize", () => isActive && calculatePositions(), { passive: true });
  window.addEventListener("scroll", () => isActive && calculatePositions(), { passive: true });

  if (document.fonts?.ready) document.fonts.ready.then(() => isActive && calculatePositions()).catch(() => {});

  if ("ResizeObserver" in window) {
    const ro = new ResizeObserver(() => isActive && calculatePositions());
    targets.forEach(el => ro.observe(el));
  }

  gsap.ticker.add(() => {
    if (!hasPointer || !isActive) return;
    for (let i = 0; i < chars.length; i++) {
      const d    = chars[i];
      const dist = Math.hypot(mouse.x - d.cx, mouse.y - d.cy);
      const w    = weightFromDistance(dist, d.minw, d.maxw, d.range);
      d.setw(clamp(w, d.minw, d.maxw));
    }
  });
}

document.addEventListener("DOMContentLoaded", () => {
  initVariableFontWeightHover();
});

Notes

  • The effect is disabled automatically on touch devices (`hover: none` or `pointer: coarse`) and when `prefers-reduced-motion` is active — in both cases the CSS-defined font weight is preserved.
  • Character center positions are cached and recalculated on resize, scroll, font load, and ResizeObserver callbacks to keep the influence radius accurate at all times.
  • Weight is wired through a CSS custom property `--wght` rather than directly animating `font-variation-settings`, making it compatible with `gsap.quickTo` and preventing conflicts with other variation axes.
  • The `fontVariationSettings` setup only runs on the first `pointermove` event, so there is zero overhead on pages where the user never moves their pointer.

Guide

Variable font requirement

The font on the target element must be a variable font that supports the `wght` axis. The script drives `font-variation-settings: 'wght' var(--wght)` per character via a CSS custom property animated with `gsap.quickTo`.

Data attributes

`data-min` — lowest weight when pointer is out of range (default 300). `data-max` — highest weight when pointer is closest (default 900). `data-range` — influence radius in px; larger values mean characters react from further away (default 500).