All components

Phive Text Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Scroll-driven sequence of pinned full-screen sections where giant uppercase words stretch open vertically (scaleY driven by each ScrollTrigger's scrub progress) and collapse as they leave; the final word scales up 10x while its dark backdrop fades to reveal a background image, then a SplitText headline reveals word-by-word.

How it's built

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

scroll pin scrub scaley text-reveal typography lenis splittext

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

Giant Stretching Text Scroll Animation

Goal

Build a scroll-driven typographic page: a sequence of full-screen pinned sections where a single giant uppercase word vertically stretches open (scaleY from 0 to the exact scale that fills the viewport) as the section scrolls into view, then collapses back to 0 while pinned. The final section instead blows the whole word block up 10x, cross-fades its dark backdrop away to reveal a full-bleed background photo, and finishes with a headline that reveals word-by-word via SplitText.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the GSAP plugins ScrollTrigger and SplitText, plus lenis for smooth scrolling. No build framework needed beyond a Vite-style dev server that resolves npm imports.

Layout / HTML

Five stacked <section> elements, each 100vw x 100svh:

  1. <section class="hero"><h1>This space intentionally loud</h1>
  2. <section class="sticky-text-1"><div class="text-container"><h1>Overdrive</h1></div>
  3. <section class="sticky-text-2"><div class="text-container"><h1>Static</h1></div>
  4. <section class="sticky-text-3"> — three children, in this order:
  5. <div class="bg-img"><img src="..." alt=""></div> (the background photo)
  6. <div class="text-container"><h1>Friction</h1></div>
  7. <div class="header"><h1>Overdrive always breaks the system</h1></div>
  8. <section class="outro"><h1>End of transmission</h1>

Load the script with <script type="module" src="./script.js">.

Styling

  • Palette as CSS custom properties on :root: --dark: rgba(17, 39, 11, 1) (near-black green) and --light: rgba(162, 255, 91, 1) (acid lime green).
  • Font: "Roboto Condensed" (Google Fonts, full variable weight range), applied to body. Global reset (* { margin:0; padding:0; box-sizing:border-box }).
  • All h1: text-transform: uppercase; font-size: 5rem; font-weight: 900; letter-spacing: -0.02em; line-height: 0.85; text-align: center;.
  • img { width:100%; height:100%; object-fit: cover; }.
  • Every section: position: relative; width: 100vw; height: 100svh; overflow: hidden;.
  • .hero and .outro: flex-centered, background var(--dark), color var(--light), their h1 at width: 50%.
  • .sticky-text-1 and .sticky-text-2: background var(--light), color var(--dark).
  • .sticky-text-3: color var(--light); its .text-container additionally gets background-color: var(--dark) (this dark plate is what fades out later).
  • .text-container and .bg-img: position: absolute; inset: 0 (top:0; left:0); width:100%; height:100%; will-change: opacity, transform; z-index: 1;.
  • .text-container h1: position: relative; left: -0.035em; letter-spacing: -0.05em; transform-origin: 50% 0%; transform: scaleY(0); — critical: the words start vertically collapsed and stretch from the TOP edge.
  • Per-section word sizes: .sticky-text-1 h1 = 23vw, font-weight: 300; .sticky-text-2 h1 = 35vw (weight 900); .sticky-text-3 .text-container h1 = 27vw, weight 900. Add will-change: transform on the first two.
  • .header: position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 50%; z-index: 2; (sits above the text-container).
  • Media query max-width: 1000px: base h1 drops to 3rem; .hero h1, .outro h1 and .header get width: calc(100% - 4rem).

GSAP effect (exhaustive)

Wrap everything in DOMContentLoaded. Register ScrollTrigger and SplitText.

Lenis smooth scroll
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
SplitText setup

Split the .header h1 with SplitText.create(header, { type: "words", wordsClass: "spotlight-word" }) and immediately gsap.set(split.words, { opacity: 0 }) so the headline starts invisible.

Dynamic target scale

For each of the three sticky sections compute targetScales[i] = section.offsetHeight / h1.offsetHeight (viewport height divided by the rendered text height). Recompute on window.resize. This is the scaleY value at which the stretched word exactly fills the section top-to-bottom. Scaling is applied by writing the inline style directly: element.style.transform = "scaleY(" + scale + ")" inside ScrollTrigger onUpdate callbacks (no tweens — everything is progress-mapped).

Sections 1 and 2 — stretch in, collapse while pinned

Each of .sticky-text-1 and .sticky-text-2 gets TWO ScrollTriggers:

  1. Entry (stretch open): trigger: section, start: "top bottom", end: "top top", scrub: 1. In onUpdate, set the h1's scaleY = targetScale * self.progress — the word grows from 0 to full viewport height as the section travels up into view.
  2. Pinned (collapse): trigger: section, start: "top top", end: "+=" + window.innerHeight + "px", pin: true, pinSpacing: false, scrub: 1. In onUpdate, set scaleY = targetScale * (1 - self.progress) — the word squashes back to 0 over one extra viewport of scroll. Because pinSpacing is false, the next section slides up and covers the pinned one while it collapses.
Section 3 — stretch in, then 10x blow-up reveal
  1. Entry: identical pattern — trigger: ".sticky-text-3", start: "top bottom", end: "top top", scrub: 1, onUpdate sets the h1 scaleY = targetScales[2] * self.progress.
  2. Pinned mega-zoom: trigger: ".sticky-text-3", start: "top top", end: "+=" + (window.innerHeight * 4) + "px", pin: true, pinSpacing: true, scrub: 1. All choreography lives in onUpdate, mapped to self.progress (0→1 across 4 viewport heights), manipulating the whole .text-container (not the h1) via inline styles:
  3. Scale phase, progress 0 → 0.75: scaleProgress = progress / 0.75; apply transform: scale3d(s, s, 1) with s = 1 + 9 * scaleProgress (i.e. 1 → 10). For progress > 0.75 clamp at scale3d(10, 10, 1).
  4. Backdrop fade, progress 0.25 → 0.5: the container's background-color stays fully opaque rgba(17, 39, 11, 1) below 0.25, then its alpha lerps 1 → 0 linearly across [0.25, 0.5] (alpha = 1 - (progress - 0.25) / 0.25, clamped 0..1), and stays alpha 0 above 0.5 — revealing the full-bleed photo underneath.
  5. Text fade, progress 0.5 → 0.75: the container's opacity lerps 1 → 0 across [0.5, 0.75] (opacity = 1 - (progress - 0.5) / 0.25); stays 0 above 0.75.
  6. At progress 0 reset: background back to opaque dark, opacity back to 1.
  7. Headline word reveal, progress 0.75 → 0.95: map textProgress = (progress - 0.75) / 0.2. For each SplitText word at index, show it with a hard cut (no fade tween): opacity = textProgress >= index / totalWords ? 1 : 0 via gsap.set(word, { opacity }) — words pop in sequentially left-to-right. Below 0.75 force all words opacity: 0; above 0.95 force all opacity: 1.

No eases, durations or staggers apply anywhere — every value is a pure linear function of ScrollTrigger progress with scrub: 1 providing ~1s of smoothing/lag.

Assets / images

One image: a full-bleed background photograph (landscape orientation, roughly 16:9 or wider, moody/atmospheric works well) that fills the third section behind the dark plate and is revealed as the plate fades out. It is displayed with object-fit: cover inside an absolutely positioned full-viewport wrapper.

Behavior notes

  • The whole page hijacks native scroll via Lenis; ScrollTrigger updates on Lenis's scroll event.
  • Target scales recompute on resize so words always stretch exactly to viewport height.
  • Sections 1–2 use pinSpacing: false (next section overlaps them); section 3 uses pinSpacing: true and consumes 4 extra viewport heights of scroll.
  • Total flow: hero → word 1 stretches/collapses → word 2 stretches/collapses → word 3 stretches, zooms 10x, dark plate fades, photo reveals, headline appears word-by-word → outro.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/phive-text-scroll-animation/img.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--dark, --signal, --paper, --muted, --light. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Mount this component twice without tearing the first pass down and you get two Lenis instances both pumping raf off the same gsap.ticker, twelve ScrollTrigger instances instead of six — for .sticky-text-1 and .sticky-text-2 that means two independent progress trackers racing to write conflicting scaleY values onto the same h1 on every scrub frame, and for .sticky-text-3 it means two pin: true triggers with pinSpacing: true stacked on the same section, doubling the four extra viewport-heights of scroll it consumes — plus a second, un-reverted SplitText.create pass on .header h1 that does not re-split plain text on the second mount: it wraps the first run's .spotlight-word spans in a fresh layer of .spotlight-word spans, so the word-count-driven reveal (index / totalWords in the final trigger's onUpdate) ends up walking a different node set than the one whose opacity is actually visible. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener body — the Lenis construction and ticker wiring, the header split, calculateDynamicScale, the resize listener, and the six ScrollTrigger.create calls — never runs: the three words sit permanently collapsed at scaleY(0), with nothing in the console to explain why. Delete the document.addEventListener("DOMContentLoaded", …) wrapper and move its body directly into a useEffect with an empty dependency array. Hoist gsap.registerPlugin(ScrollTrigger, SplitText) out to module scope above the component — the source calls it from inside the listener, but it is one-time, page-wide registration, not per-mount state.

*(2) Element lookups* — Every lookup here is a bare document.querySelector: .header h1 (the split target), the three .sticky-text-{1,2,3} .text-container h1 nodes (textElement1/2/3), .sticky-text-3 .text-container (textContainer3), and — inside calculateDynamicScale, which runs once at setup and again on every resize — the paired .sticky-text-${i} / .sticky-text-${i} .text-container h1 for i from 1 to 3. Give the component a root ref on the wrapper containing all five sections (.hero through .outro) and resolve every one of those through it instead of through document. During the StrictMode remount two copies of the section markup exist for an instant; an unscoped lookup can hand calculateDynamicScale the outgoing copy's offsetHeight while the triggers that read targetScales a moment later are scrubbing the incoming copy, so the word stretches to the wrong fraction of the viewport.

*(3) Cleanup* — Wrap the Lenis wiring, the header split, calculateDynamicScale's first call, and all six ScrollTrigger.create calls in one gsap.context scoped to the root ref:

useEffect(() => {
  const lenis = new Lenis();
  const driveLenis = (time) => lenis.raf(time * 1000);
  let headerSplit = null;
  const targetScales = [];

  function calculateDynamicScale() {
    for (let i = 1; i <= 3; i++) {
      const section = rootRef.current.querySelector(`.sticky-text-${i}`);
      const text = rootRef.current.querySelector(`.sticky-text-${i} .text-container h1`);
      if (!section || !text) continue;
      targetScales[i - 1] = section.offsetHeight / text.offsetHeight;
    }
  }

  const ctx = gsap.context(() => {
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add(driveLenis);
    gsap.ticker.lagSmoothing(0);

    const header = rootRef.current.querySelector(".header h1");
    if (header) {
      headerSplit = SplitText.create(header, { type: "words", wordsClass: "spotlight-word" });
      gsap.set(headerSplit.words, { opacity: 0 });
    }

    calculateDynamicScale();
    // the six ScrollTrigger.create calls, each `trigger` and each h1 /
    // .text-container reference resolved through rootRef.current.querySelector
  }, rootRef);

  window.addEventListener("resize", calculateDynamicScale);

  return () => {
    window.removeEventListener("resize", calculateDynamicScale);
    gsap.ticker.remove(driveLenis);
    lenis.destroy();
    ctx.revert();
    headerSplit?.revert();
  };
}, []);

ctx.revert() kills all six triggers — unpinning .sticky-text-3 and dropping its spacer, restoring .sticky-text-1/2 to their unpinned layout — and reverts every inline transform/opacity/background-color the onUpdate callbacks wrote via element.style and gsap.set. It does not reach gsap.ticker.add(driveLenis): a ticker subscription is neither a tween nor a trigger, so without the explicit gsap.ticker.remove above, the ticker keeps calling driveLenis, and driveLenis keeps calling .raf() on a Lenis instance whose destroy() has already run. lenis.on("scroll", ScrollTrigger.update) needs no separate teardown — it lives on the Lenis instance's own emitter and goes with lenis.destroy(). The window resize listener is a plain DOM subscription the context never tracked either way, hence the explicit removeEventListener.

headerSplit.revert() has to run after ctx.revert(), not before: while the triggers are still live, the final section's onUpdate is still reading headerSplit.words on every scrub frame to decide which .spotlight-word gets opacity: 1, and reverting the split out from under a still-scrubbing trigger unwraps the very spans that callback is about to set opacity on. Revert the split only once the trigger that reads it is gone. Guard the call (headerSplit?.revert()) since header can be absent and headerSplit then stays null, exactly as the source's own if (header) guard allows.