Spotlight Text Reveal
A soft spotlight travels over dim text, word by word, and each word it passes lights up and stays lit. It follows the lines as the browser wraps them, so it works on a whole sentence too.
The light finds every word, one at a time, and leaves it shining.
How to use it
- Paste the snippet just before
</head>. - Add
data-spotlight-text-revealto a heading or paragraph on a dark background.
Options
Add any of these attributes to the same element.
data-color | The colour of the light. Default warm white #fff3d6. |
data-dim | How visible the words are before they’re lit, from 0 to 1. Default 0.14. |
data-step | Time the light spends on each word. Default 280 ms. |
data-loop | Light every word, hold, dim them and sweep again. |
data-hold | How long the words stay lit before dimming, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spotlight Text Reveal: a soft spotlight travels over dim text, word by
// word, and each word it passes lights up and stays lit. Best on a dark
// background.
// Usage: <h2 data-spotlight-text-reveal>Every word in the light</h2>
// Optional: data-color (the light, default warm white #fff3d6), data-dim
// (how visible unlit words are, 0 to 1, default 0.14), data-step (ms the
// light spends on each word, default 280), data-loop (dim the words and
// sweep again), data-hold (ms to stay lit before dimming), data-delay (ms
// before starting).
var DEFAULTS = { color: '#fff3d6', dim: 0.14, step: 280, hold: 1800, delay: 0 };
var css =
'.str-on{position:relative;isolation:isolate}' +
'.str-word{display:inline-block;position:relative;z-index:1}' +
'.str-spot{position:absolute;left:0;top:0;border-radius:50%;pointer-events:none;opacity:0;z-index:0}';
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.textContent = '';
el.classList.add('str-on');
var spot = document.createElement('span');
spot.className = 'str-spot';
spot.setAttribute('aria-hidden', 'true');
el.appendChild(spot);
var words = text.split(' ').map(function (word, i) {
if (i) el.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'str-word';
w.setAttribute('aria-hidden', 'true');
w.textContent = word;
el.appendChild(w);
return w;
});
var color = d.color || DEFAULTS.color;
spot.style.background = 'radial-gradient(closest-side, ' + color + ' 0%, transparent 100%)';
return { spot: spot, words: words };
}
function play(el) {
var d = el.dataset, s = el._str;
var loop = d.loop !== undefined && d.loop !== 'false';
var step = +d.step || DEFAULTS.step;
var dim = d.dim !== undefined ? +d.dim : DEFAULTS.dim;
var fs = parseFloat(getComputedStyle(el).fontSize);
var size = fs * 3.4;
// Measured fresh every sweep, so it follows the text when it re-wraps.
var pts = s.words.map(function (w) {
return { x: w.offsetLeft + w.offsetWidth / 2, y: w.offsetTop + w.offsetHeight / 2 };
});
var sp = s.spot;
sp.style.width = sp.style.height = size + 'px';
sp.style.marginLeft = sp.style.marginTop = -size / 2 + 'px';
anime.utils.set(sp, { x: pts[0].x - fs * 1.4, y: pts[0].y, opacity: 0, scale: 0.8 });
anime.utils.set(s.words, { opacity: dim });
var tl = anime.createTimeline({
delay: +d.delay || DEFAULTS.delay,
onComplete: function () {
if (!loop) return;
setTimeout(function () {
anime.animate(s.words, {
opacity: dim, duration: 700, ease: 'inOut(2)', delay: anime.stagger(30),
onComplete: function () { setTimeout(function () { play(el); }, 300); }
});
}, +d.hold || DEFAULTS.hold);
}
});
tl.add(sp, { opacity: 0.55, scale: 1, duration: step * 1.5, ease: 'out(2)' }, 0);
pts.forEach(function (p, i) {
var t = step * (i + 0.6);
// A jump to the next line takes a little longer than a step along one.
var jump = i && p.y !== pts[i - 1].y;
tl.add(sp, { x: p.x, y: p.y, duration: jump ? step * 1.4 : step, ease: 'inOut(1.6)' }, t);
tl.add(s.words[i], { opacity: [dim, 1], duration: step * 2.2, ease: 'out(2)' }, t + step * 0.45);
});
var end = step * (pts.length + 0.8);
var last = pts[pts.length - 1];
tl.add(sp, { x: last.x + fs * 1.6, opacity: 0, scale: 1.2, duration: step * 2.4, ease: 'inOut(2)' }, end);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-spotlight-text-reveal]').forEach(function (el) {
if (el._str) return;
el._str = setup(el);
if (reduced()) return;
var dim = el.dataset.dim !== undefined ? +el.dataset.dim : DEFAULTS.dim;
el._str.words.forEach(function (w) { w.style.opacity = dim; });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h2 data-spotlight-text-reveal>Step into the light</h2>
<!-- or, slower, with a cool light, looping: -->
<h2 data-spotlight-text-reveal data-color="#bae6fd" data-step="400" data-loop>
Step into the light
</h2>