← Rotating words

Highlight Hop

A highlight box glides from word to word through your sentence, lighting each one up in turn and following it across line breaks. Hover or tap a word and the box hops straight to it.

Design it, build it, ship it, love it

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-highlight-hop to a sentence. The box visits every word.
  3. To highlight only some words, wrap each in <span data-hop>.

Options

Add any of these attributes to the same element.

data-holdHow long the box rests on each word. Default 1000 ms.
data-durationTime for each hop. Default 550 ms.
data-colorThe box’s colour. Default #6a0dad.
data-text-colorThe highlighted word’s colour. Default white.
data-orderrandom hops to a random word each time instead of going in order.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Highlight Hop: a highlight box glides from word to word through a
    // sentence, lighting each one up in turn. It follows the words across line
    // breaks, and hovering or tapping a word sends the box straight to it.
    // Usage: <p data-highlight-hop>Plan it, build it, ship it</p>
    //    or: <p data-highlight-hop>Write <span data-hop>less</span>, ship <span data-hop>more</span></p>
    //        (only the marked words get the highlight)
    // Optional: data-hold (ms per word, default 1000), data-duration (ms per
    // hop, default 550), data-color (the box, default #6a0dad), data-text-color
    // (the highlighted word, default #fff), data-order ("random" hops at random
    // instead of in order).
    var DEFAULTS = { hold: 1000, duration: 550, color: '#6a0dad', textColor: '#fff' };

    var css =
      '.hhp{position:relative;isolation:isolate}' +
      '.hhp-w,.hhp [data-hop]{position:relative;z-index:1;transition:color .3s ease}' +
      '.hhp-box{position:absolute;left:0;top:0;z-index:0;border-radius:.18em;pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Wrap every word of the element's own text in a span, leaving any child
    // elements as they are.
    function splitWords(el) {
      Array.prototype.slice.call(el.childNodes).forEach(function (node) {
        if (node.nodeType !== 3) return;
        var frag = document.createDocumentFragment();
        node.textContent.split(/(\s+)/).forEach(function (part) {
          if (!part) return;
          if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; }
          // Trailing punctuation stays outside the highlight.
          var m = part.match(/^(.*?[^.,!?;:]?)([.,!?;:]*)$/);
          if (!m[1]) { frag.appendChild(document.createTextNode(part)); return; }
          var s = document.createElement('span');
          s.className = 'hhp-w';
          s.textContent = m[1];
          frag.appendChild(s);
          if (m[2]) frag.appendChild(document.createTextNode(m[2]));
        });
        el.replaceChild(frag, node);
      });
      return Array.prototype.slice.call(el.querySelectorAll('.hhp-w'));
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('hhp');
      var targets = Array.prototype.slice.call(el.querySelectorAll('[data-hop]'));
      if (!targets.length) targets = splitWords(el);
      var box = document.createElement('span');
      box.className = 'hhp-box';
      box.setAttribute('aria-hidden', 'true');
      box.style.background = d.color || DEFAULTS.color;
      el.insertBefore(box, el.firstChild);
      return { el: el, targets: targets, box: box, i: -1, pausedUntil: 0, visible: false, started: false };
    }

    // Where the box should sit to cover target i: a little wider than the word.
    function rect(s, i) {
      var t = s.targets[i];
      var pad = parseFloat(getComputedStyle(s.el).fontSize) * 0.1;
      return { x: t.offsetLeft - pad, y: t.offsetTop, width: t.offsetWidth + pad * 2, height: t.offsetHeight };
    }

    function light(s, i) {
      var col = s.el.dataset.textColor || DEFAULTS.textColor;
      s.targets.forEach(function (t, k) { t.style.color = k === i ? col : ''; });
    }

    function hop(s, i, instant) {
      if (i === s.i) return;
      var first = s.i < 0;
      s.i = i;
      var r = rect(s, i);
      light(s, i);
      if (instant || first) {
        anime.utils.set(s.box, { x: r.x, y: r.y, width: r.width, height: r.height });
        if (first && !instant) anime.animate(s.box, { opacity: [0, 1], scale: [0.6, 1], duration: 400, ease: 'out(3)' });
        else s.box.style.opacity = 1;
        return;
      }
      anime.animate(s.box, {
        x: r.x, y: r.y, width: r.width, height: r.height,
        duration: +s.el.dataset.duration || DEFAULTS.duration,
        ease: 'inOut(4)'
      });
      // A quick squash on take-off makes it feel like a hop, not a slide.
      anime.animate(s.box, { scaleY: [1, 0.82, 1], duration: (+s.el.dataset.duration || DEFAULTS.duration), ease: 'inOut(2)' });
    }

    function nextIndex(s) {
      var n = s.targets.length;
      if (s.el.dataset.order === 'random' && n > 2) {
        var j;
        do { j = Math.floor(Math.random() * n); } while (j === s.i);
        return j;
      }
      return (s.i + 1) % n;
    }

    function start(s) {
      var d = s.el.dataset;
      var every = (+d.hold || DEFAULTS.hold) + (+d.duration || DEFAULTS.duration);
      hop(s, 0);
      var place = function () { if (s.i >= 0) { var r = rect(s, s.i); anime.utils.set(s.box, { x: r.x, y: r.y, width: r.width, height: r.height }); } };
      if (window.ResizeObserver) new ResizeObserver(place).observe(s.el);
      else window.addEventListener('resize', place);
      if (document.fonts) document.fonts.ready.then(place);
      // Hover or tap a word to send the box there; the tour waits a moment.
      s.targets.forEach(function (t, k) {
        var go = function () { s.pausedUntil = Date.now() + 2500; hop(s, k); };
        t.addEventListener('pointerenter', go);
        t.addEventListener('pointerdown', go);
      });
      (function loop() {
        setTimeout(function () {
          if (s.visible && !document.hidden && Date.now() >= s.pausedUntil) hop(s, nextIndex(s));
          loop();
        }, every);
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._hhp;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-highlight-hop]').forEach(function (el) {
        if (el._hhp) return;
        el._hhp = setup(el);
        if (!el._hhp.targets.length) return;
        // Reduced motion: the first word stays highlighted.
        if (reduced) hop(el._hhp, 0, true);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-highlight-hop>Design it, build it, ship it</p>

<!-- or, only some words: -->
<p data-highlight-hop>
  Write <span data-hop>less</span>, ship <span data-hop>more</span>.
</p>