Scroll-brightened paragraph
A paragraph lit word by word as it scrolls, with a soft front several words wide — light moving across the text rather than a cursor stepping through it.
How it works
- The front has width. Lighting exactly one word at a time reads as a cursor stepping through the text. Six words in partial brightness reads as light moving across it:
t = clamp((head − i) / 6, 0, 1), wherehead = p × (n + 6). - 🩸 The dim state is applied by JS, never declared in CSS. Ship
opacity: .16in the stylesheet and the day the script does not run — an error three lines up, a blocked bundle, an old browser — the paragraph is an invisible block of text. Full brightness is the resting state; dimming is something the effect does to it. - Split on words, never on characters. Per-character spans break find-in-page, break double-click-to-select, and make a screen reader spell the sentence out one letter at a time. Nothing here needs finer than a word.
- The whitespace between words stays as text nodes in the paragraph rather than inside the spans, so copying the text out gives back the original sentence and line-breaking still works normally.
- Words fade to
0.16, not to 0. A word at zero is a hole in the paragraph and the block visibly reflows in the reader's peripheral vision; at 0.16 the shape of the sentence is intact and only its emphasis moves. - Progress is divided by 0.82 so the last word lights while the paragraph is still comfortably on screen. Mapping the front to the full scroll range finishes the sentence exactly as it leaves the viewport, which nobody ever sees.
prefers-reduced-motion: reducepins every word to full opacity with!importantand the script returns early — the paragraph is just a paragraph.
Numbers
- Scroll distance
- 340% of the viewport
- Front width
- 6 words
- Dim floor
- 0.16 opacity
- Progress range
- 0 → 0.82 of the track
- Split unit
- word (/(\s+)/, spaces kept outside)
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven word-by-word brightening paragraph in vanilla HTML/CSS/JS. A scroll container with a 340%-tall track and a sticky stage (height: 100vh, never 100%) holding one paragraph. Put the paragraph in the HTML as plain readable text at full brightness — do NOT declare a dim state in CSS, or the text becomes invisible whenever the script fails to run. In JS split the text on /(\s+)/, wrap each word in a span, and append the whitespace back as plain text nodes so copy, find-in-page and line-breaking are unaffected; never split per character. On scroll compute p = clamp(scrollTop / max / 0.82, 0, 1), then head = p * (wordCount + 6) + 2, and set each word's opacity to 0.16 + 0.84 * clamp((head - i) / 6, 0, 1) — the divisor 6 is what gives the brightening front a width instead of stepping one word at a time. Fade to 0.16, never to 0, so the paragraph does not develop holes. Under prefers-reduced-motion: reduce force every word to opacity 1 with !important and return from the scroll handler immediately. Register the scroll listener passive.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-brightened paragraph</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
.scroller { height: 100%; overflow-y: auto; background: #08080c; scrollbar-width: thin; }
.track { height: 340%; }
/* 🩸 100vh,不是 100%(`.track` 高 340%)。 */
.stage {
position: sticky; top: 0; height: 100vh; overflow: hidden;
background: #08080c;
display: grid; align-content: center;
padding: 0 7%;
}
.rule { height: 1px; background: linear-gradient(90deg, #c99a3f, #c99a3f22 70%, transparent); }
.eyebrow {
margin: 14px 0 18px;
font-size: 10px; letter-spacing: 0.24em; text-transform: uppercase; color: #ffffff5c;
}
p {
font-size: clamp(17px, 3.5vw, 34px); line-height: 1.32;
font-weight: 400; letter-spacing: -0.022em;
/* The paragraph's real colour. Words are dimmed by opacity on top of this,
so the bright end never depends on JS having got the colour right. */
color: #f4f1ea;
}
/* 🩸 No dim start state in CSS. If the words began at opacity .12 here and the
script never ran — old browser, blocked bundle, an error three lines up —
the paragraph would be an invisible block of text with no error anywhere.
Full brightness is the resting state; the dimming is applied by JS. */
p i { font-style: normal; will-change: opacity; }
@media (prefers-reduced-motion: reduce) {
p i { opacity: 1 !important; }
}
.cue {
position: absolute; left: 7%; bottom: 8%;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff44;
}
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage">
<div class="rule"></div>
<div class="eyebrow">01 — About</div>
<p id="copy">A designer who builds. Eight years spent between product teams and the browser, turning tools that demo well into workflows that survive a Tuesday afternoon.</p>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
<script>
const scroller = document.getElementById("scroller");
const p = document.getElementById("copy");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* 🩸 Split on words, never on characters. Per-character spans break find-in-page,
break double-click-to-select, and make a screen reader spell the sentence out
one letter at a time. Nothing about this effect needs finer than a word. */
const words = p.textContent.split(/(\s+)/);
p.textContent = "";
const spans = [];
for (const w of words) {
if (/^\s+$/.test(w)) { p.appendChild(document.createTextNode(w)); continue; }
const el = document.createElement("i");
el.textContent = w;
p.appendChild(el);
spans.push(el);
}
const N = spans.length;
const SOFT = 6; // words the brightening front spans at once
const FLOOR = 0.16; // how dark a word gets before its turn
const update = () => {
if (reduce) return;
const max = scroller.scrollHeight - scroller.clientHeight;
const raw = max > 0 ? scroller.scrollTop / max : 0;
// Finish before the very bottom, so the last words are lit while the
// paragraph is still on screen rather than exactly as it leaves.
const prog = Math.min(1, Math.max(0, raw / 0.82));
// +2 so a couple of words are already lit at rest: a paragraph that is
// uniformly dim before you touch it reads as a rendering fault, not as an effect.
const head = prog * (N + SOFT) + 2;
for (let i = 0; i < N; i++) {
// A front with width. Lighting exactly one word at a time reads as a
// cursor stepping through the text; several words mid-way reads as light
// moving across it.
const t = Math.min(1, Math.max(0, (head - i) / SOFT));
spans[i].style.opacity = (FLOOR + (1 - FLOOR) * t).toFixed(3);
}
};
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", update);
update();
</script>Where it breaks
- The dimmed words are decorative but still real text: at 0.16 opacity on a dark ground they are far below any contrast threshold. That is defensible only while they are transient — if the reader can stop the page with words still dim (a short viewport, an interrupted scroll), they are unreadable content, not an effect. Test at 360px wide before shipping.
- One span per word on a long article is thousands of style writes per scroll event. Past a few hundred words, update only the words currently in view, or move the whole thing to a CSS scroll-driven animation with a
view()timeline and no JS at all. - Chinese and Japanese have no spaces, so the word split degrades to one span for the entire sentence and the effect vanishes with no error. Segment with
Intl.Segmenterandgranularity: "word"for those languages.
Seen in
@派大鑫 (Douyin) · “这是我 Vibe Coding 的个人站” · 0:10