Rolling Text Hover
Hover a link and its letters roll up out of sight one after another, as a copy of the word rolls up from underneath to take its place. Move away and it rolls back.
How to use it
- Paste the snippet just before
</head>. - Add
data-rolling-text-hoverto a link with plain text in it.
Options
Add any of these attributes to the same element.
data-color | Colour of the copy that rolls in. Default the link’s own colour. |
data-stagger | Gap between letters. Default 22 ms. Set 0 to roll the whole word at once. |
data-duration | How long each letter takes to roll. Default 500 ms. |
data-autoplay | Links that share a parent take turns rolling while the pointer isn’t on any. Handy for demos. |
The link’s text is rebuilt from its plain text, so keep icons outside it. Screen readers still hear the word once.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Rolling Text Hover: hover a link and its text rolls up out of sight, letter
// by letter, as a copy of itself rolls up from below to take its place.
// Usage: <a href="/work" data-rolling-text-hover>Work</a>
// Optional: data-stagger (ms between letters, default 22; 0 rolls the whole
// word at once), data-duration (ms per letter, default 500), data-color
// (colour of the copy that rolls in, default the link's own colour),
// data-autoplay (links that share a parent take turns rolling while the
// pointer isn't on any; handy for demos and touch screens).
var DEFAULTS = { stagger: 22, duration: 500 };
var css =
'.rth{display:inline-flex;overflow:hidden;vertical-align:bottom;line-height:1.2;text-decoration:none}' +
'.rth-c{position:relative;display:inline-block;white-space:pre;' +
'transition:transform var(--rth-d) cubic-bezier(.7,0,.2,1) calc(var(--i) * var(--rth-s))}' +
'.rth-c>span{position:absolute;left:0;top:100%}' +
'.rth-on .rth-c{transform:translateY(-100%)}' +
'@media (prefers-reduced-motion:reduce){.rth-c{transition:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.classList.add('rth');
el.style.setProperty('--rth-s', (d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger) + 'ms');
el.style.setProperty('--rth-d', (d.duration !== undefined ? +d.duration : DEFAULTS.duration) + 'ms');
el.textContent = '';
text.split('').forEach(function (ch, i) {
var c = document.createElement('span');
c.className = 'rth-c';
c.setAttribute('aria-hidden', 'true');
c.style.setProperty('--i', i);
c.textContent = ch;
var copy = document.createElement('span');
copy.textContent = ch;
if (d.color) copy.style.color = d.color;
c.appendChild(copy);
el.appendChild(c);
});
function enter() { el.classList.add('rth-on'); }
function leave() { el.classList.remove('rth-on'); }
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._rthReal = true; enter(); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._rthReal = false; leave(); } });
// Touch: a tap rolls it up and back down again.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._rthHold = Date.now() + 4000;
enter();
setTimeout(leave, 900);
});
el.addEventListener('focus', enter);
el.addEventListener('blur', leave);
return { el: el, enter: enter, leave: leave };
}
// Links that share a parent take turns rolling.
function autoplay(group) {
var i = 0, timer = null, visible = false;
function busy() {
return group.some(function (g) {
return g.el._rthReal || g.el === document.activeElement || (g.el._rthHold && Date.now() < g.el._rthHold);
});
}
function step() {
if (!visible) return;
if (!busy()) {
var cur = group[i++ % group.length];
cur.enter();
setTimeout(function () { if (!cur.el._rthReal && cur.el !== document.activeElement) cur.leave(); }, 1100);
}
timer = setTimeout(step, 1400);
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(step, 400); }
}).observe(group[0].el.parentElement || group[0].el);
}
function init() {
var groups = [];
document.querySelectorAll('[data-rolling-text-hover]').forEach(function (el) {
if (el._rth) return;
el._rth = true;
var link = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
var g = groups.filter(function (g) { return g.parent === el.parentElement; })[0];
if (!g) groups.push((g = { parent: el.parentElement, links: [] }));
g.links.push(link);
});
groups.forEach(function (g) { autoplay(g.links); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><nav>
<a href="/work" data-rolling-text-hover>Work</a>
<a href="/about" data-rolling-text-hover>About</a>
<a href="/contact" data-rolling-text-hover data-color="#a855f7">Contact</a>
</nav>