Text Highlight Scroll
Thirty-three words at 15% opacity in a 280 px box that scrolls over 620 px; on every scroll event of the container, the shipped listener lights floor(ratio × 33) words in #e0e7ff, turns the next one violet and moves a 3 px bar along — text and gauge sit in a sticky block so they stay in view up to the last word.
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 nests .demo-preview (background #0a0a0f, white text, sans-serif, position: relative: the block carries its own background and font, so it stays readable when pasted into a white page) → .sdc-scroll-container.ss-highlight-scroll (width: 100%, height: 280px, overflow-y: auto, scroll-behavior: smooth, 4 px WebKit scrollbar) → .ss-highlight-content (height: 900px) → .ss-highlight-pin (position: sticky, top: 0, padding: 40px 16px) → .ss-highlight-text#ssHighlightText (line-height: 2) holding 33 span.ss-highlight-word, then a 3 px .ss-highlight-progress-track (rgba(255,255,255,.1), margin-top: 16px) and its fill #ssHighlightFill (width: 0, #8b5cf6 → #d946ef gradient, transition: width .1s). Each word is a 1rem inline-block, font-weight: 600, in rgba(255,255,255,.15) with transition: color .2s; two state classes: .lit (#e0e7ff) and .accent (#a78bfa). Below the box, .sdc-scroll-hint "Scrollez ↓" (.65rem, opacity pulsing from .3 to .7 over 2 s, sdcHintPulse). Measured on a blank 800 px wide page: the paragraph takes 3 lines of 32 px, from 40 to 136 px below the top of the box; the track ends at 155 px; the pinned block is 195 px tall; the remaining 705 px of content are empty and only serve to provide 620 px of travel (900 − 280). At 1,280 px, 2 lines (64 px) and a 163 px block.
The listener is an ES5 IIFE (var, function, no global variable) that walks document.querySelectorAll('.ss-highlight-scroll'): each box finds its text and its fill inside itself (container.querySelector), so several boxes on the same page are animated independently. The words are the spans of the HTML — the script rebuilds nothing through innerHTML, so the whitespace between tags is kept (measured gap between two words: 4.45 px at 1rem) and you change the text by editing the spans. On every scroll event of the container (passive listener), and on resize: ratio = scrollTop / (scrollHeight − clientHeight), litCount = Math.floor(ratio × 33); words with index i < litCount get .lit, the one with i === litCount gets .accent (the word "being read", in violet), and fill.style.width = ratio × 100 + '%'. Measured: at 100 px of scroll, 5 words lit and the 6th in violet, bar at 16.1%; at 300 px, 15; at 600 px, 31; at 620 px, all 33 in #e0e7ff, no accent left, bar at 100%. One word every 18.8 px of scroll. The function is called once at initialisation: before any scrolling, no word is lit but the first one already carries the accent and the bar is at 0%.
Why the block is pinned. The paragraph is 96 px tall in a 280 px window with 620 px of travel: if it scrolled with the content, it would leave the frame entirely at scrollTop = 136 (ratio 0.219, 7 words out of 33) and the next 26 words would light up out of view. .ss-highlight-pin with position: sticky; top: 0 inside .ss-highlight-content settles this: measured, the text stays from 40 to 136 px below the top of the box at every position from 0 to 620 px, the track at 155 px. A sticky element never leaves its parent: the 195 px block can glide down to 900 − 195 = 705 px, beyond the 620 px of travel, so it never detaches. At 390 px wide, the paragraph grows to 6 lines (192 px) and the block to 291 px, taller than the window: at the end of travel it is pushed up by 11 px (text from 29 to 221 px), still entirely visible. Detail: the "Scrollez ↓" hint is positioned relative to .demo-preview (bottom: 8px) and overlaps the last 10 px of the box.
Accessibility
- prefers-reduced-motion handled in the CSS:
@media (prefers-reduced-motion: reduce) { .sdc-scroll-hint { animation: none } }stops the pulsing of the "Scrollez ↓" hint (sdcHintPulse, opacity .3 ↔ .7 every 2 s, infinite), the effect's only autonomous animation. The highlight is driven by the user's own scrolling, so there is no vestibular risk; the color (.2 s) and width (.1 s) transitions stay active. - Contrast measured on
#0a0a0f: unread wordsrgba(255,255,255,.15)=#2f2f33, 1.48:1 — unreadable by design until you scroll; read words#e0e7ff16.0:1; current word#a78bfa7.3:1; bar 4.7 to 5.7:1; "Scrollez ↓" hint 2.6:1 at 10.4 px. If the sentence carries information (a manifesto, a programme), raise the base opacity to.45(4.5:1): the effect remains a progressive emphasis and stops being a reveal. - Screen readers: the 33 spans are ordinary inline text, read as one sentence whatever the scroll position — do not put
aria-hiddenon them. Do put it on the "Scrollez ↓" hint and on the progress track (adivwith no role), or give the trackrole="progressbar"witharia-valuenowupdated in the listener'supdatefunction. - Keyboard: the
overflow-y: autobox has notabindex. Chrome 130+ makes scrollable regions without focusable content focusable, not every browser does: addtabindex="0"and anaria-labelto the container so arrow keys and Space scroll it. Touch: one-finger scrolling of a nestedoverflow-y: autobox works on iOS and Android and does firescrollevents; at the end of travel, wheel and finger hand over to the page (nooverscroll-behavioris set).
Browser compatibility
ES5 (var, function, Array.prototype.forEach.call on the NodeList), classList, scroll event on an overflow-y: auto container; on the CSS side, position: sticky, transition, linear-gradient, scroll-behavior (optional) and ::-webkit-scrollbar (WebKit/Blink only, Firefox keeps its native bar). No animation-timeline: unlike the 45 effects of this category driven by a CSS timeline (fx-0545, fx-0546, fx-0592…), which Firefox reads in no version, this highlight works in Firefox. No dependency: no library, no image, no hosted file — the three pasted blocks are enough.
Without JavaScript: a paragraph at 15% opacity, pinned, in a 280 px box that scrolls over nothing, an empty bar, a pulsing hint. Without position: sticky, the block scrolls with the content and leaves the frame at 136 px while the words keep lighting up. Without scroll-behavior, programmatic scrolling is instant; without ::-webkit-scrollbar, the bar is the system's.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="sdc-scroll-container ss-highlight-scroll">
<div class="ss-highlight-content">
<div class="ss-highlight-pin">
<div class="ss-highlight-text" id="ssHighlightText">
<span class="ss-highlight-word">Le </span>
<span class="ss-highlight-word">scrollytelling </span>
<span class="ss-highlight-word">transforme </span>
<span class="ss-highlight-word">… </span>
</div>
<div class="ss-highlight-progress-track">
<div class="ss-highlight-progress-fill" id="ssHighlightFill"></div>
</div>
</div>
</div>
</div>
<span class="sdc-scroll-hint">Scroll ↓</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 |
|---|---|---|
| Text (HTML — the span.ss-highlight-word elements) | 33 words, one span per word | The script counts the spans present in .ss-highlight-text at initialisation: 12 words or 80, the pace adapts (travel ÷ word count). Keep the trailing space inside each span or a line break between them: with inline-block, that is what separates the words (4.45 px at 1rem). Several .ss-highlight-scroll boxes on the same page are each animated by the same IIFE. |
| Pinned block (CSS — .ss-highlight-pin position / padding) | sticky; top: 0; padding 40px 16px | Keeps text and track in the frame for the whole travel: measured, the paragraph stays from 40 to 136 px below the top of the box from 0 to 620 px of scroll. position: static restores the behavior of a paragraph scrolling with its content (it leaves the frame at 136 px while 7 words are lit). The padding sets where the text sits in the window. |
| Content height (CSS — .ss-highlight-content height) | 900px (620 px of travel) | Travel = height − 280 px. It sets the pace: 620 / 33 = 18.8 px per word. 500px (220 px of travel, 6.7 px per word) for a one-flick read; 1400px for a slow read. The pinned block (195 px at 800 px wide) stays attached as long as it is shorter than the 280 px window, whatever the content height. |
| Box height (CSS — .sdc-scroll-container height) | 280px | Visible window. Taller, the travel shrinks for the same content (900 − height). Since the text is pinned, the height only needs to hold the paragraph and the track: 195 px at 800 px wide, 291 px at 390 px (beyond the window, the block is pushed up by the difference at the end of travel — 11 px, without hiding the text). |
| Word colors (CSS — .ss-highlight-word, .lit, .accent) | rgba(255,255,255,.15) / #e0e7ff / #a78bfa | Three states. The base opacity sets readability before reading (1.48:1 at .15, 4.5:1 at .45). Only one word carries the accent at a time — the first one from load; after the last word, no accent remains. |
| Color transition (CSS — .ss-highlight-word transition) | color .2s | Fade of each word to .lit / .accent. At 0, the lighting follows the scroll frame by frame; at .5s, a trail: several words fading at once after a wheel flick. |
| Typography (CSS — .ss-highlight-word font-size / font-weight, .ss-highlight-text line-height) | 1rem / 600 / 2 | inline-block on each word: words never break at the end of a line and the spaces come from the HTML between the spans (4.45 px at 1rem), which the script preserves since it does not rewrite the HTML. A line-height of 1.5 to 1.6 is enough at large sizes. |
| Progress bar (CSS — .ss-highlight-progress-fill) | #8b5cf6 → #d946ef gradient, 3 px, transition width .1s | Width written in % by the listener. Remove track and fill if you do not want them: the listener checks if (fillEl) before writing. |
| Smooth scrolling (CSS — .sdc-scroll-container scroll-behavior) | smooth | Applies to programmatic jumps (scrollTo, anchors): the words then light up one by one during the glide. Switch to auto if you drive scrollTop yourself frame by frame. |
FAQ
.ss-highlight-scroll boxes already present: place it after the HTML (end of body), or load it with defer if it lives in the head. The words must be span.ss-highlight-word elements inside .ss-highlight-text: that is what the script counts. And the box must actually scroll — 900 px of content in a 280 px window; if you shrank .ss-highlight-content below the box height, there is no travel left and the ratio stays at 0. Measured on a blank page with the three blocks pasted as they are: at 100 px of mouse wheel, 5 words lit and the 6th in violet, no console error..ss-highlight-pin, a position: sticky; top: 0 block inside 900 px of content. The box scrolls (the scrollbar and the gauge show it), the paragraph stays from 40 to 136 px below the top and the words light up under your eyes — without that, a 96 px paragraph leaves a 280 px window at 136 px of scroll, 7 words lit out of 33, and the next 26 would light up out of view. For text that scrolls with its content, set .ss-highlight-pin { position: static } and write a paragraph long enough to fill the travel, or bring the content height down to what the text occupies.floor(ratio × n) formula in a 280 px container, with four differences. fx-0579 targets a single container by id in ES2015 (arrow functions) and only acts on the first scroll; fx-0591 is an ES5 IIFE that animates every .ss-highlight-scroll box on the page and accents the first word from load. fx-0591 adds the progress bar and pins the text; fx-0579 has neither gauge nor pinning. fx-0579 uses display: inline (.3 s transition, read words in #fff, current #a855f7) on 64 words; fx-0591, inline-block, .2 s, #e0e7ff / #a78bfa on 33 words. Finally fx-0579's text fills its box and scrolls with it; fx-0591's stays in place. Take fx-0579 for a long paragraph read by scrolling; fx-0591 for a short sentence that stays in view, with a gauge.