Scroll Animated Text Reveal (word-by-word highlight pills)
Goal
Build a one-page scroll experience where two paragraph sections are pinned while scrolling and their text reveals word by word: each word fades in behind a dark grey rounded "highlight pill", the pill then dissolves to expose the letters, special keywords reveal inside brightly colored pills, and past 70% of the pinned scroll the whole sequence re-highlights the words in reverse (letters fade back out behind grey pills).
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scrolling. No other libraries. Three files: index.html (links ./styles.css, loads <script type="module" src="./script.js">), styles.css, script.js.
Layout / HTML
Five full-viewport <section> elements, in this order:
<section class="hero">— contains<div class="copy-container">with an<h1>: "Playground for bold ideas and creative interfaces."<section class="about anime-text-container">—<div class="copy-container">wrapping<div class="anime-text">with two<p>paragraphs of marketing copy for a fictional design tool called "Huebase". The copy must naturally contain the keywords vibrant, living, clarity, expression (e.g. "Huebase is a vibrant space for designers who think in motion… bold ideas turn into living interfaces… great design starts with clarity and expression ends… your palette comes to life…"). Roughly 40–50 words per paragraph.<section class="cta">—copy-containerwith<h1>: "Join Huebase now to create expressive interfaces."<section class="features anime-text-container">— same structure as.about(acopy-container>anime-text> two<p>), with copy that naturally contains the keywords shape, intuitive, storytelling, interactive, vision (e.g. "Huebase brings motion, structure, and creativity together in one intuitive space… explore rich storytelling visuals… interactive components… bring your creative vision to life…").<section class="outro">—copy-containerwith<h1>: "Built for designers who shape the web."
Styling
- Google Font "DM Sans" on
body; global reset (* { margin:0; padding:0; box-sizing:border-box }). body { background-color: #141414 }.- Every
section:position: relative; width: 100vw; height: 100svh; padding: 2em; overflow: hidden. .copy-container: fills its section (width/height: 100%), flex centered both axes,text-align: center,border-radius: 2rem..copy-container h1:width: 70%, color#141414(dark text on colored cards),font-size: 5rem,font-weight: 900,line-height: 1.- Colored card backgrounds:
.hero .copy-container { background: #fe6d38 }(orange),.cta .copy-container { background: #c6fe69 }(lime green),.outro .copy-container { background: #7a78ff }(violet). .about .copy-container, .features .copy-container: no background, just a dashed border0.15rem dashed rgb(60, 60, 60)..anime-text { width: 60% };.anime-text p: color#fff,text-align: center,margin-bottom: 2rem,font-size: 2rem,font-weight: 900,line-height: 1.- Word wrappers (created by JS):
.anime-text .word { display: inline-block; position: relative; margin-right: 0.2rem; margin-bottom: 0.2rem; padding: 0.1rem 0.2rem; border-radius: 2rem; will-change: background-color, opacity }. Keyword wrappers get extra horizontal room:.anime-text .word.keyword-wrapper { margin: 0 0.4rem 0.2rem 0.2rem }. - Inner text span:
.anime-text .word span { position: relative }. - Keyword spans:
.anime-text .word span.keyword { border-radius: 2rem; display: inline-block; width: 100%; height: 100%; padding: 0.1rem 0; color: #141414 }plus a::beforepseudo-element that draws the colored pill behind the text:content: ""; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: calc(100% + 1rem); height: calc(100% + 0.4rem); background-color: #fff; border-radius: 2rem; z-index: -1. - Per-keyword pill colors (class on the span equals the keyword itself):
vibrant,shape,interactive→#7a78ff;living,expression,storytelling→#fe6d38;clarity,intuitive,vision→#c6fe69. - Initial state in CSS:
.anime-text .word, .anime-text .word span { opacity: 0 }— both the wrapper and the inner span start invisible; JS drives their opacity inline.
GSAP effect (exhaustive)
Setup
- All code runs inside
DOMContentLoaded. gsap.registerPlugin(ScrollTrigger).- Lenis smooth scroll with default options, wired into GSAP the canonical way:
lenis.on("scroll", ScrollTrigger.update)gsap.ticker.add((time) => lenis.raf(time * 1000))gsap.ticker.lagSmoothing(0)
Word splitting (manual, no SplitText)
For every .anime-text p:
- Take
textContent, split on whitespace (/\s+/), clear the paragraph'sinnerHTML. - For each non-empty word create
<div class="word"><span>WORD</span></div>and append it to the paragraph. - Normalize the word (lowercase, strip punctuation
[.,!?;:"]). If it matches one of the nine keywords["vibrant","living","clarity","expression","shape","intuitive","storytelling","interactive","vision"], add classkeyword-wrapperto the.worddiv and add classeskeywordand the normalized word itself to the inner span (this is what selects the pill color in CSS).
ScrollTrigger — one per text section
For each .anime-text-container (the .about and .features sections), create a ScrollTrigger with:
trigger: container,pin: container,start: "top top",end: "+=" + window.innerHeight * 4(pinned for 4 viewport heights),pinSpacing: true.- No tweens/timelines — everything is computed in
onUpdate(self)fromself.progress(0→1 across the pinned distance) and written as inline styles on each word. Collect all.anime-text .wordelements in the container (totalWords= count; both paragraphs together). The grey highlight color isrgba(60, 60, 60, α).
Phase 1 — reveal (progress ≤ 0.7)
Map scroll progress to revealProgress = min(1, progress / 0.7). Each word animates over its own staggered window using this exact math (a manual stagger with 15-word overlap):
overlapWords = 15totalAnimationLength = 1 + overlapWords / totalWordswordStart = index / totalWords;wordEnd = wordStart + overlapWords / totalWordstimelineScale = 1 / min(totalAnimationLength, 1 + (totalWords - 1) / totalWords + overlapWords / totalWords)adjustedStart = wordStart * timelineScale;adjustedEnd = wordEnd * timelineScale;duration = adjustedEnd - adjustedStartwordProgress= 0 beforeadjustedStart, 1 afteradjustedEnd, otherwise linear(revealProgress - adjustedStart) / duration.
Then per word, per frame:
- Wrapper opacity:
word.style.opacity = wordProgress(the grey pill fades in with the word). - Pill background: fully grey while
wordProgress < 0.9, then fades out linearly over the last 10%:backgroundFadeStart = wordProgress >= 0.9 ? (wordProgress - 0.9) / 0.1 : 0;word.style.backgroundColor = rgba(60,60,60, max(0, 1 - backgroundFadeStart)). - Text opacity: hidden until
wordProgressreaches the 0.9 threshold, then revealed with a square-root curve:textRevealProgress = wordProgress >= 0.9 ? (wordProgress - 0.9) / 0.1 : 0;span.style.opacity = Math.pow(textRevealProgress, 0.5).
Net effect: a grey rounded pill sweeps across the paragraphs word by word; as each word's pill completes, the pill dissolves and the letters appear in its place (keywords keep their colored CSS pill behind the text).
Phase 2 — reverse re-highlight (progress > 0.7)
reverseProgress = (progress - 0.7) / 0.3. Wrapper opacity is forced to 1. Same staggered-window math but with reverseOverlapWords = 5:
reverseWordStart = index / totalWords;reverseWordEnd = reverseWordStart + reverseOverlapWords / totalWordsreverseTimelineScale = 1 / max(1, (totalWords - 1) / totalWords + reverseOverlapWords / totalWords)reverseWordProgress= clamped linear over the scaled window, as before.
Per word: if reverseWordProgress > 0, span.style.opacity = 1 - reverseWordProgress and word.style.backgroundColor = rgba(60,60,60, reverseWordProgress) — the grey pill swallows the text again, word by word from the top. Otherwise text opacity stays 1 and background alpha 0.
Scrolling back up plays everything in reverse automatically since it's all derived from self.progress.
Assets / images
None. The component is pure typography and colored CSS shapes — no images, icons, or SVGs.
Behavior notes
- The page relies on Lenis smooth scrolling for the buttery pinned feel; both pinned sections behave identically.
- Responsive (
@media (max-width: 1000px)):h1→width: 90%; font-size: 2rem;.anime-text→width: 90%;.anime-text p→font-size: 1.25rem;.word→margin-right: 0.1rem; margin-bottom: 0.15rem; padding: 0.1rem 0.2rem;.word.keyword-wrapper→margin: 0 0.2rem 0.1rem 0.1rem. - No reduced-motion handling, no resize recalculation needed beyond ScrollTrigger defaults.