← Hover & cursor

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

  1. Paste the snippet just before </head>.
  2. Add data-rolling-text-hover to a link with plain text in it.

Options

Add any of these attributes to the same element.

data-colorColour of the copy that rolls in. Default the link’s own colour.
data-staggerGap between letters. Default 22 ms. Set 0 to roll the whole word at once.
data-durationHow long each letter takes to roll. Default 500 ms.
data-autoplayLinks 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.

1 · Snippet
<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>
2 · Your HTML
<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>