Scroll-scrubbed Text
The words of a paragraph light up one by one as you scroll through it, and dim again if you scroll back. It’s tied to the scroll position, so the reader sets the pace.
Scroll inside ↓
Every word lights up as you scroll, so the reader sets the pace and nothing is missed.
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-scrubbed-textto a paragraph or heading.
Options
Add any of these attributes to the same element.
data-dim | Opacity of words not reached yet. Default 0.18. |
data-color | Colour words turn as they light up. |
Works inside scrolling boxes too: it follows the nearest scrolling parent, or the page.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll-scrubbed Text: the words of a paragraph light up one by one as you
// scroll through it, and dim again if you scroll back. Tied to the scroll
// position, so the reader sets the pace.
// Usage: <p data-scroll-scrubbed-text>Every word lights up as you read…</p>
// Optional: data-dim (opacity of words not reached yet, default 0.18),
// data-color (colour words turn as they light up).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { dim: 0.18 };
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
return text.split(' ').map(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var s = document.createElement('span');
s.textContent = w;
s.setAttribute('aria-hidden', 'true');
el.appendChild(s);
return s;
});
}
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
}
return null;
}
function setup(el) {
var d = el.dataset;
var dim = d.dim !== undefined ? +d.dim : DEFAULTS.dim;
var words = split(el);
var box = scroller(el);
var color = d.color;
var base = getComputedStyle(el).color;
var last = -1;
function update() {
var r = el.getBoundingClientRect();
var top = 0, h = window.innerHeight;
if (box) { var b = box.getBoundingClientRect(); top = b.top; h = box.clientHeight; }
// 0 when the paragraph's top reaches 85% down the view, 1 when its bottom
// passes 45% down: the words light up while it crosses the middle.
var start = top + h * 0.85, end = top + h * 0.45;
var p = (start - r.top) / (start - end + r.height);
p = Math.max(0, Math.min(1, p));
var lit = p * words.length;
if (Math.abs(lit - last) < 0.01) return;
last = lit;
words.forEach(function (w, i) {
var t = Math.max(0, Math.min(1, lit - i));
w.style.opacity = dim + (1 - dim) * t;
if (color) w.style.color = t > 0.5 ? color : base;
});
}
var queued = false;
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(function () { queued = false; update(); });
}
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-scroll-scrubbed-text]').forEach(function (el) {
if (el._sst) return;
el._sst = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-scroll-scrubbed-text>
We build tools that get out of your way, so you can
spend your time on the work that matters.
</p>