Curtain image revealWeb · App
Good motion explains what just happened, where things came from and where they went. It is a specification, not decoration, and every value in it should be a decision you can name.
Target
Implement the "Scroll text highlight" effect in vanilla HTML/CSS/JS. Context: Words of a paragraph brighten one after another as the reader scrolls, like a highlighter following the eye. Trigger: the page scrolls; progress is bound to scroll position, not time. Timeline: scroll-progress-based (map scroll position to animation progress; no fixed duration). Motion tokens (normal): - opacity (each word): 0.25 → 1 - scroll range: cover 15% (paragraph) → cover 65% - easing: linear - stagger: per word, evenly across the range Motion tokens (prefers-reduced-motion): - opacity (whole paragraph): 0.25 → 1 - scroll range: cover 15% → cover 65% - easing: linear - note: no per-word stagger Technique: prefer CSS `animation-timeline: scroll()/view()` inside `@supports (animation-timeline: scroll())`, with a JS fallback; GSAP (ScrollTrigger where relevant) is acceptable as an alternative. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - split words with inline spans; keep the text selectable and readable at opacity 0.25 - animate opacity only, never color (keeps it off the main thread)
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-scroll-text-highlight" data-fx-root>
<div class="fx-scroller" tabindex="0" aria-label="Scrollable article">
<div class="fx-spacer"></div>
<p class="fx-text">Good motion explains what just happened, where things came from and where they went. It is a specification, not decoration, and every value in it should be a decision you can name.</p>
<div class="fx-spacer"></div>
</div>
</div>
demo.css
.fx-scroll-text-highlight { position: relative; width: 100%; height: 100%; background: #fff; }
.fx-scroll-text-highlight .fx-scroller { position: absolute; inset: 0; overflow-y: auto; }
.fx-scroll-text-highlight .fx-spacer { height: 75%; }
.fx-scroll-text-highlight .fx-text { margin: 0; padding: 0 24px; font: 500 clamp(17px, 2.6vw, 26px)/1.4 Inter, system-ui, sans-serif; color: var(--fx-ink, #18181b); view-timeline: --fx-para block; }
.fx-scroll-text-highlight .fx-w { opacity: .25; }
@supports (animation-timeline: view()) {
.fx-scroll-text-highlight.use-css .fx-w { animation: fx-sth linear both; animation-timeline: --fx-para; animation-range: cover var(--s, 15%) cover var(--e, 65%); }
@keyframes fx-sth { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .fx-scroll-text-highlight.use-css .fx-w { animation-range: cover 15% cover 65%; } }
}
demo.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), para = root.querySelector('.fx-text');
if (!para.querySelector('.fx-w')) {
const words = para.textContent.trim().split(/\s+/);
para.textContent = '';
words.forEach((w, i) => {
const s = document.createElement('span'); s.className = 'fx-w'; s.textContent = w;
const a = 15 + (50 * i) / words.length, b = 15 + (50 * (i + 1)) / words.length; // slice of the 15%→65% range
s.style.setProperty('--s', a.toFixed(2) + '%'); s.style.setProperty('--e', b.toFixed(2) + '%');
para.appendChild(s); para.appendChild(document.createTextNode(' '));
});
}
const words = Array.from(para.querySelectorAll('.fx-w'));
const supported = CSS.supports('animation-timeline: view()');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
root.dataset.fxMode = supported ? 'css' : 'js-fallback';
const onScroll = () => {
const sr = scroller.getBoundingClientRect(), pr = para.getBoundingClientRect();
const cover = (sr.bottom - pr.top) / (sr.height + pr.height); // 0 = about to enter, 1 = fully left
const p = Math.max(0, Math.min(1, (cover - 0.15) / 0.5));
words.forEach((w, i) => { const t = reduced ? p : Math.max(0, Math.min(1, p * words.length - i)); w.style.opacity = String(0.25 + 0.75 * t); });
};
return {
play() { if (supported) root.classList.add('use-css'); else { scroller.addEventListener('scroll', onScroll, { passive: true }); onScroll(); } },
stop() { root.classList.remove('use-css'); scroller.removeEventListener('scroll', onScroll); words.forEach((w) => (w.style.opacity = '')); },
};
}
demo.gsap.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), para = root.querySelector('.fx-text');
if (!para.querySelector('.fx-w')) {
const words = para.textContent.trim().split(/\s+/); para.textContent = '';
words.forEach((w) => { const s = document.createElement('span'); s.className = 'fx-w'; s.textContent = w; para.appendChild(s); para.appendChild(document.createTextNode(' ')); });
}
const words = para.querySelectorAll('.fx-w');
let tween;
return {
play() {
const { gsap, ScrollTrigger } = window; if (!gsap || !ScrollTrigger) { root.dataset.fxMode = 'gsap-missing'; return; }
gsap.registerPlugin(ScrollTrigger); root.dataset.fxMode = 'gsap';
tween = gsap.fromTo(words, { opacity: .25 }, { opacity: 1, ease: 'none', stagger: 1, scrollTrigger: { scroller, trigger: para, start: 'top 85%', end: 'bottom 35%', scrub: true } });
},
stop() { tween?.scrollTrigger?.kill(); tween?.kill(); tween = undefined; words.forEach((w) => (w.style.opacity = '')); },
};
}
Inspired by gsap.com — the SplitText + scrub word reveal seen on many agency sites. The preview is an original re-creation; the effect name belongs to its author.
Per-word stagger without JS timing: every word shares the paragraph's named `view-timeline` and gets its own slice of the range through `--s`/`--e` custom properties. The GSAP version gets the same look from `stagger: 1` on a scrubbed tween (relative units, so the whole stagger spreads across the scroll distance).
Added 2026-11-09 · Report an issue