All components

Scroll Animated Text Reveal

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-driven, word-by-word text reveal where Lenis smooth scroll feeds GSAP ScrollTrigger, which pins each text section over four viewport heights and, via manual per-word progress math in onUpdate, fades words in behind a grey highlight pill that then dissolves to expose the letters; select keywords reveal inside colored rounded pills, and past 70% progress the sequence re-highlights the words in reverse.

How it's built

Categorytext
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll scrub pinning text-reveal word-by-word highlight lenis editorial bold

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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:

  1. <section class="hero"> — contains <div class="copy-container"> with an <h1>: "Playground for bold ideas and creative interfaces."
  2. <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.
  3. <section class="cta">copy-container with <h1>: "Join Huebase now to create expressive interfaces."
  4. <section class="features anime-text-container"> — same structure as .about (a copy-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…").
  5. <section class="outro">copy-container with <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 border 0.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 ::before pseudo-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:

  1. Take textContent, split on whitespace (/\s+/), clear the paragraph's innerHTML.
  2. For each non-empty word create <div class="word"><span>WORD</span></div> and append it to the paragraph.
  3. Normalize the word (lowercase, strip punctuation [.,!?;:"]). If it matches one of the nine keywords ["vibrant","living","clarity","expression","shape","intuitive","storytelling","interactive","vision"], add class keyword-wrapper to the .word div and add classes keyword and 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) from self.progress (0→1 across the pinned distance) and written as inline styles on each word. Collect all .anime-text .word elements in the container (totalWords = count; both paragraphs together). The grey highlight color is rgba(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 = 15
  • totalAnimationLength = 1 + overlapWords / totalWords
  • wordStart = index / totalWords; wordEnd = wordStart + overlapWords / totalWords
  • timelineScale = 1 / min(totalAnimationLength, 1 + (totalWords - 1) / totalWords + overlapWords / totalWords)
  • adjustedStart = wordStart * timelineScale; adjustedEnd = wordEnd * timelineScale; duration = adjustedEnd - adjustedStart
  • wordProgress = 0 before adjustedStart, 1 after adjustedEnd, 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 wordProgress reaches 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 / totalWords
  • reverseTimelineScale = 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)): h1width: 90%; font-size: 2rem; .anime-textwidth: 90%; .anime-text pfont-size: 1.25rem; .wordmargin-right: 0.1rem; margin-bottom: 0.15rem; padding: 0.1rem 0.2rem; .word.keyword-wrappermargin: 0 0.2rem 0.1rem 0.1rem.
  • No reduced-motion handling, no resize recalculation needed beyond ScrollTrigger defaults.