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:
<section class="hero">—<h1>This space intentionally loud</h1><section class="sticky-text-1">—<div class="text-container"><h1>Overdrive</h1></div><section class="sticky-text-2">—<div class="text-container"><h1>Static</h1></div><section class="sticky-text-3">— three children, in this order:<div class="bg-img"><img src="..." alt=""></div>(the background photo)<div class="text-container"><h1>Friction</h1></div><div class="header"><h1>Overdrive always breaks the system</h1></div><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;. .heroand.outro: flex-centered, backgroundvar(--dark), colorvar(--light), theirh1atwidth: 50%..sticky-text-1and.sticky-text-2: backgroundvar(--light), colorvar(--dark)..sticky-text-3: colorvar(--light); its.text-containeradditionally getsbackground-color: var(--dark)(this dark plate is what fades out later)..text-containerand.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. Addwill-change: transformon 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: baseh1drops to3rem;.hero h1,.outro h1and.headergetwidth: 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:
- Entry (stretch open):
trigger: section, start: "top bottom", end: "top top", scrub: 1. InonUpdate, set the h1'sscaleY = targetScale * self.progress— the word grows from 0 to full viewport height as the section travels up into view. - Pinned (collapse):
trigger: section, start: "top top", end: "+=" + window.innerHeight + "px", pin: true, pinSpacing: false, scrub: 1. InonUpdate, setscaleY = targetScale * (1 - self.progress)— the word squashes back to 0 over one extra viewport of scroll. BecausepinSpacingis false, the next section slides up and covers the pinned one while it collapses.
Section 3 — stretch in, then 10x blow-up reveal
- Entry: identical pattern —
trigger: ".sticky-text-3", start: "top bottom", end: "top top", scrub: 1,onUpdatesets the h1scaleY = targetScales[2] * self.progress. - Pinned mega-zoom:
trigger: ".sticky-text-3", start: "top top", end: "+=" + (window.innerHeight * 4) + "px", pin: true, pinSpacing: true, scrub: 1. All choreography lives inonUpdate, mapped toself.progress(0→1 across 4 viewport heights), manipulating the whole.text-container(not the h1) via inline styles: - Scale phase, progress 0 → 0.75:
scaleProgress = progress / 0.75; applytransform: scale3d(s, s, 1)withs = 1 + 9 * scaleProgress(i.e. 1 → 10). For progress > 0.75 clamp atscale3d(10, 10, 1). - Backdrop fade, progress 0.25 → 0.5: the container's
background-colorstays fully opaquergba(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. - Text fade, progress 0.5 → 0.75: the container's
opacitylerps 1 → 0 across [0.5, 0.75] (opacity = 1 - (progress - 0.5) / 0.25); stays 0 above 0.75. - At progress 0 reset: background back to opaque dark, opacity back to 1.
- Headline word reveal, progress 0.75 → 0.95: map
textProgress = (progress - 0.75) / 0.2. For each SplitText word atindex, show it with a hard cut (no fade tween):opacity = textProgress >= index / totalWords ? 1 : 0viagsap.set(word, { opacity })— words pop in sequentially left-to-right. Below 0.75 force all wordsopacity: 0; above 0.95 force allopacity: 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
scrollevent. - 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 usespinSpacing: trueand 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.