Letter Hover Lift
Letters near the pointer rise up and lean apart, as if a magnifier were passing under the line, and spring back down when it leaves. Move along the word and the bump follows.
Hover over me
How to use it
- Paste the snippet just before
</head>. - Add
data-letter-hover-liftto a heading or link.
Options
Add any of these attributes to the same element.
data-lift | How high the nearest letter rises, in em. Default 0.35. |
data-spread | How far letters lean apart, in em. Default 0.12. |
data-radius | How far the effect reaches either side of the pointer, in em. Default 2. |
data-color | A colour the lifted letters take. |
data-autoplay | A pretend pointer sweeps along the text whenever a real one isn’t over it. Handy for demos and touch screens. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Letter Hover Lift: letters near the pointer rise up and lean apart, as if
// the pointer were a magnifier passing under the line. They spring back
// down when it leaves.
// Usage: <h1 data-letter-hover-lift>Hover over me</h1>
// Optional: data-lift (how high the nearest letter rises, in em; default
// 0.35), data-spread (how far letters lean apart, in em; default 0.12),
// data-radius (how far the effect reaches either side of the pointer, in
// em; default 2), data-color (colour the lifted letters take), data-autoplay
// (a pretend pointer sweeps along the text while no real one is over it;
// handy for demos and touch screens).
var DEFAULTS = { lift: 0.35, spread: 0.12, radius: 2 };
var css =
'.lhl-word{display:inline-block;white-space:nowrap}' +
'.lhl-char{display:inline-block;transform-origin:50% 100%;transition:color .35s;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function split(el) {
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 = 'lhl-word';
word.setAttribute('aria-hidden', 'true');
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.className = 'lhl-char';
s.textContent = c;
word.appendChild(s);
chars.push({ el: s, anim: anime.createAnimatable(s, { x: 350, y: 350, scale: 350, ease: 'out(3)' }) });
});
el.appendChild(word);
});
return chars;
}
// Each letter's centre on screen, worked out from layout (offsets ignore
// transforms), so letters that are already lifted don't move the target.
function measure(el, chars) {
var r = el.getBoundingClientRect();
chars.forEach(function (c) {
var s = c.el;
c.cx = r.left + el.clientLeft + s.offsetLeft + s.offsetWidth / 2;
c.cy = r.top + el.clientTop + s.offsetTop + s.offsetHeight / 2;
});
}
function setup(el) {
var d = el.dataset;
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var chars = split(el);
var fs = parseFloat(getComputedStyle(el).fontSize);
var lift = fs * (d.lift !== undefined ? +d.lift : DEFAULTS.lift);
var spread = fs * (d.spread !== undefined ? +d.spread : DEFAULTS.spread);
var radius = fs * (+d.radius || DEFAULTS.radius);
var base = getComputedStyle(el).color;
// Lift every letter for a pointer at (px, py), in screen coordinates.
function update(px, py) {
chars.forEach(function (c) {
var dx = c.cx - px;
var dy = (c.cy - py) * 0.6;
var dist = Math.sqrt(dx * dx + dy * dy);
// A smooth bump: 1 right under the pointer, 0 at the radius.
var f = dist < radius ? (1 + Math.cos(Math.PI * dist / radius)) / 2 : 0;
// Duration and ease every time: an animatable keeps the last ones it
// was given, and rest() springs back with slower ones.
c.anim.y(-lift * f, 350, 'out(3)');
c.anim.x((dx / radius) * spread * 2 * f, 350, 'out(3)');
c.anim.scale(1 + 0.14 * f, 350, 'out(3)');
if (d.color) c.el.style.color = f > 0.02 ? 'color-mix(in srgb, ' + d.color + ' ' + Math.round(f * 100) + '%, ' + base + ')' : '';
});
}
function rest() {
chars.forEach(function (c) {
c.anim.y(0, 900, 'outElastic(1, .5)');
c.anim.x(0, 900, 'outElastic(1, .5)');
c.anim.scale(1, 900, 'outElastic(1, .5)');
c.el.style.color = '';
});
}
var hovering = false;
el.addEventListener('pointerenter', function () { hovering = true; measure(el, chars); if (sweep) sweep.pause(); });
el.addEventListener('pointermove', function (e) { measure(el, chars); update(e.clientX, e.clientY); });
el.addEventListener('pointerleave', function () {
hovering = false;
rest();
if (sweep && visible) setTimeout(function () { if (!hovering && visible) sweep.play(); }, 1200);
});
// A pretend pointer that glides from one end of the text to the other and back.
var sweep = null;
var visible = false;
if (d.autoplay !== undefined && d.autoplay !== 'false') {
var pos = { p: 0 };
sweep = anime.animate(pos, {
p: 1,
duration: Math.max(1600, chars.length * 160),
ease: 'inOutSine',
loop: true,
alternate: true,
autoplay: false,
onUpdate: function () {
measure(el, chars);
var r = el.getBoundingClientRect();
// Run off both ends, so the letters at each end settle too.
update(r.left - radius * 0.6 + (r.width + radius * 1.2) * pos.p, chars[0].cy);
}
});
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !hovering) sweep.play(); else sweep.pause();
}).observe(el);
}
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-letter-hover-lift]').forEach(function (el) {
if (el._lhl) return;
el._lhl = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-letter-hover-lift>Hover over me</h1>
<!-- turns purple as it lifts: -->
<h1 data-letter-hover-lift data-color="#a855f7">Hover over me</h1>