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'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).