Text Highlight on Scroll
Sixty-four words at 15% opacity that a scroll listener lights one by one: index = ⌊scrollTop ÷ range × 64⌋, i.e. 3.4 px of scrolling per word in a 280 px box, 300 ms fade, the current word in purple. Nothing is lit before the first scroll, and at that height the words mostly light up after they have left the frame.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
The sold HTML is a .scroll-container#highlight-scroll box 280px tall, overflow: hidden auto and scroll-behavior: smooth, holding .highlight-container#highlight-text (padding: 24px 20px) with 64 inline span.highlight-word — .95rem (15.2 px), weight 500, line-height: 2 (30.4 px) —, then a spacer <div style="height: 300px"> and a .scroll-hint-label reading “Scrollez pour explorer” that pulses from .3 to .7 opacity over 2 s (@keyframes pulseHint). Three color states, with transition: color .3s: unlit rgba(255,255,255,.15), .lit white, .accent #a855f7. No CSS variables, no dependency; the sold text is French written without its accents (“defilement”, “revele”, “cree”).
The script (12 lines, a single scroll listener on the box) computes on every event t = scrollTop / (scrollHeight − clientHeight) then g = Math.floor(t × 64): words with an index below g get lit, word g gets accent, every other word is reset. Measured in Chromium on a 784 px wide box: 5 lines, scrollHeight 500 px, a scroll range of 220 px, i.e. 3.44 px of scrolling per word; one 40 px wheel notch flips 11 to 12 words at once, which the 300 ms fade spreads into a wave. The granularity is the whole pixel, not the sub-pixel: scrollTop = 10.11 reads back 10 and 10.51 reads back 11, at DPR 1 as at DPR 2. At t = 1, g = 64: everything is lit and the accent disappears. No initial pass: before the first scroll, 0 words lit, 0 accent. And since scroll-behavior: smooth also applies to writes to scrollTop, a position set by script is reached through an animation — read right after the write, it is still the old value.
The index follows the scroll range, not the geometry of the text: with the 300 px spacer, the range (220 px) exceeds the height of the text (200 px). Measured at 784 px wide, position by position: the accented word is inside the frame for only 76 of the 221 positions, always between −17 and +29 px from the top edge; it leaves at 47 px of scrolling (13th word), never comes back after 107 px, and from 80 px on no lit word is visible at all — only the still-unlit lines remain on screen, and the last 52 px of range scroll without a single word in the frame. At 390 px wide (10 lines, 372 px range, 5.8 px per word): 178 positions out of 373, exit at 108 px. In other words, at 280 px tall the words mostly light up after leaving the frame; the effect keeps its promise in a box much taller than the text, or with the range matched to the text height (see Customize). The code is also single-instance: top-level const (pasted twice: SyntaxError: Identifier 'highlightScroll' has already been declared), a unique getElementById and a page-wide querySelectorAll — with two boxes, measured, the first is fully lit at mid-range (64 words out of 128 counted) and the second stays inert. Nothing is ever removed, but there is nothing to stop: no loop, one listener on one element.
Accessibility
- prefers-reduced-motion not in the code: measured under emulation, the 300 ms transition and the label's 2 s pulse keep going. The motion is driven by the reader (nothing moves on its own, except the label); still add
@media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } .highlight-word { transition: none } .scroll-container { scroll-behavior: auto } }— smooth scrolling is an animation too. - Contrast: the unlit words are text to be read, not decoration —
rgba(255,255,255,.15)on#0a0a0fmeasures 1.48:1, unreadable until the word is reached (and nothing is lit before the first scroll). Lit, 19.75:1; accent#a855f7, 4.99:1 — compliant at 15.2 px. Raise the unlit state to.45(4.5:1) if the text must be readable ahead, or accept the masking and provide the content elsewhere. On a light background the code does not hold: 1.0:1 unlit, 3.96:1 for the accent. The “Scrollez pour explorer” label peaks at 2.6:1 and pulses between 1.2 and 1.8:1: it is an instruction, make it readable or remove it. - Keyboard and touch: measured in Chromium, Tab focuses the box (keyboard-focusable scrollers) and each down arrow moves 40 px, i.e. 11 to 12 words. Firefox and Safari do not focus a scrolling box without
tabindex="0"on#highlight-scroll— add it, with a visible:focus-visible. Wheel and nested touch scrolling work. The 4 px bar styled with::-webkit-scrollbarstays native on Firefox (scrollbar-width: thin). - Screen readers and no JavaScript: 64 inline
spans separated by spaces inside adiv— the text is read as a sentence, the lit state (classes only) is not announced, which is fine for a decorative highlight; put the text in a<p>and restore the accents. Without a script, or until someone scrolls, the whole text stays at 15%: provide a<noscript>that forces the full color, or trigger a first pass on load (highlightScroll.dispatchEvent(new Event('scroll'))).
Browser compatibility
The script is ES2015 (const, arrow functions, NodeList.forEach, classList.remove(a, b)) and the CSS relies on a single recent property: the two-value form overflow: hidden auto — a browser that does not understand it drops the declaration, the box no longer scrolls and the effect is dead. No animation-timeline or scroll-timeline: the highlight comes from a classic scroll listener, so it works in Firefox, which supports CSS scroll-driven animations in no version. Zero dependencies.
Without JavaScript the box scrolls but the whole text stays at 15% opacity: no word lights up, only the label pulses. Without overflow: hidden auto (Chrome < 68, Firefox < 61, Safari < 12.1), the box stretches to its content height and nothing scrolls.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="scroll-container" id="highlight-scroll">
<div class="highlight-container" id="highlight-text">
<span class="highlight-word">…</span>
</div>
<div style="height: 300px"></div>
</div>
<span class="scroll-hint-label">…</span>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Box height (CSS — .scroll-container { height: 280px }) |
280 px | Reading window. The taller the box relative to the text, the longer the lit word stays visible: at 280 px for 200 px of text, it leaves the frame after 47 px of scrolling. A full-screen section (100vh) with two or three screens of text is the favorable case. |
End spacer (HTML — <div style="height: 300px">) |
300 px | Extends the range past the text: the last words light up when the text has already left (52 px of range with no text at 784 px wide). Set it to box height − padding − one line (226 px here) so the last line still lights up inside the frame, or raise padding-top and set spacer = box − paddings: the range then equals the text height and the current word settles at the padding height. |
Placement of the current word (JS — Math.floor(t * i)) |
proportional to the range | To tie the lighting to the real position of the words, replace the computation with a geometric test: lit if w.offsetTop − scrollTop < clientHeight × .5, accent on the first word that fails. The current word then stays at mid-height whatever the width, and the spacer only serves to reach the last line. |
Unlit color (CSS — .highlight-word { color: rgba(255,255,255,.15) }) |
white at 15% (1.48:1) | Readability of the text not yet reached. .3 = 2.6:1 (you can guess), .45 = 4.5:1 (you can read). On a light background, use a gray (#9ca3af) — translucent white vanishes there. |
Lit and accent colors (CSS — .lit { color: #fff }, .accent { color: #a855f7 }) |
#fff / #a855f7 | The accent marks the single current word; #a855f7 holds 4.99:1 on #0a0a0f, #c084fc 7.5:1, an amber #fbbf24 11.8:1 reads as a highlighter. An accent can also carry a background: the transition only covers color, add background-color. |
Fade (CSS — transition: color .3s) |
300 ms | Duration of the unlit → lit change. One wheel notch flips 11 to 12 words at once: at .3s they blend into a wave, at 0 they snap, at .8s the wave trails behind the scroll. |
Type size and line height (CSS — font-size: .95rem, line-height: 2) |
15.2 px / 30.4 px | Set the number of lines, hence the text height and the density of words per pixel of range (3.44 px per word at 784 px wide, 5.8 px at 390 px). In large type (26 px, 1.5 line height), 29 words over 195 px give 6.7 px per word: ceremonial reading. |
Smooth scrolling and label (CSS — scroll-behavior: smooth, .scroll-hint-label) |
smooth / “Scrollez pour explorer” pulsing over 2 s | smooth also animates scrollTop values set by script (read right after: the old value): switch it to auto if you drive the position. The label is a catalog leftover in French: translate it or remove the span and the pulseHint animation. |
FAQ
Math.floor(scrollTop / range × 64): it advances with the scroll range, not with the position of the lines. With the 300 px spacer and a 280 px box, the range (220 px at 784 px wide) is longer than the text (200 px) and starts the first word at the top of the frame: for every pixel scrolled the text moves up one pixel while the current word advances only 0.29 word — it is caught by the edge after 47 px, never visible again after 107 px, and from 80 px on only unlit words remain in view. Two fixes: match the range to the text height (padding-top equal to the wanted reading height, spacer = box − paddings), or replace the computation with a per-word geometric test (offsetTop − scrollTop < clientHeight × .5). The three scenes on this page use the first fix: their current word stays visible over the whole range, between 53 and 129 px from the top in the first two, between 106 and 156 px in the third.inline-block words, a #ssHighlightFill progress bar) and the three CSS states, with an empty JavaScript field: nothing in it adds lit or fills the bar. fx-0579 ships the 12-line listener that does the effect, with inline words (wrapping follows the text, not the word) and a .3 s fade instead of .2 s. Neither relies on animation-timeline; fx-0579's highlight therefore works in Firefox, which does not implement CSS scroll-driven animations.highlightScroll and highlightWords as top-level const: pasted a second time, it stops on SyntaxError: Identifier 'highlightScroll' has already been declared. And even wrapped in a function, it targets getElementById("highlight-scroll") — a single element — and counts every .highlight-word on the page: measured with two boxes, the first is fully lit at mid-range (64 words out of 128 counted) and the second never reacts. For several instances, wrap the code in document.querySelectorAll('.scroll-container').forEach(c => { const words = c.querySelectorAll('.highlight-word'); … }): one listener per box, each counting only its own words — which is how scenes 2 and 3 on this page are instantiated.