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
- Paste the snippet just before
</head>. - Add
data-highlight-hopto a sentence. The box visits every word. - To highlight only some words, wrap each in
<span data-hop>.
Options
Add any of these attributes to the same element.
data-hold | How long the box rests on each word. Default 1000 ms. |
data-duration | Time for each hop. Default 550 ms. |
data-color | The box’s colour. Default #6a0dad. |
data-text-color | The highlighted word’s colour. Default white. |
data-order | random hops to a random word each time instead of going in order. |
<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><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>