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
- Paste the snippet just before
</head>. - Add
data-proximity-weightto 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-min | Weight of letters far from the pointer. Default 300. |
data-max | Weight of the letter right under it. Default 900. |
data-radius | How far the swell reaches either side of the pointer, 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. |
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.
<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><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>