Telescope Image Scroll Animation
Goal
Build a scroll-driven "telescope" hero: a pinned full-screen banner where an image container scales up from 0 while six stacked copies of the same photo — each clipped by a silhouette-shaped CSS mask — grow at staggered scales, producing a lens-within-a-lens telescoping depth effect. Two large intro words slide apart horizontally and a centered headline fades in word-by-word near the end of the scrub.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), plus the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scrolling. No frameworks, no build-specific code — plain import statements at the top of script.js.
Layout / HTML
Three full-viewport sections in <body>:
<section class="hero">— a centered<h1>with the text "The frame is only the beginning."<section class="banner">— the animated section. Inside:<div class="banner-img-container">containing, in this order:- 7 image layers, each
<div class="img"><img src="..." alt="" /></div>. All 7 use the same photo. The first layer has only classimg(the unmasked base). The remaining 6 have classimg mask(silhouette-masked copies). <div class="banner-header"><h1>The Season Wears Confidence</h1></div>— the headline that reveals word-by-word.<div class="banner-intro-text-container">with two children:<div class="banner-intro-text"><h1>Surface</h1></div><div class="banner-intro-text"><h1>Layered</h1></div><section class="outro">— a centered<h1>with the text "And that’s the silhouette."
Load script.js with <script type="module" src="./script.js"></script> at the end of <body>.
Styling
- Google Font Instrument Serif (
@importin the CSS, weights regular + italic available; only regular is used).body { font-family: "Instrument Serif", sans-serif; }. - Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. img { width: 100%; height: 100%; object-fit: cover; will-change: transform; }.h1 { font-size: 4rem; line-height: 1.1; }.- Every
section:position: relative; width: 100vw; height: 100svh; background-color: #e3e3db; color: #141414; overflow: hidden;. .heroand.outro: flex, centered both axes; theirh1iswidth: 50%; text-align: center;..banner-img-container:position: relative; width: 100%; height: 100%; will-change: transform;..banner-img-container .img:position: absolute; top: 0; left: 0; width: 100%; height: 100%; will-change: transform;(all 7 layers stack on top of each other)..banner-img-container .img.mask: apply the silhouette PNG as a CSS mask —
mask-image: url(<mask png>) (plus -webkit-mask-image), mask-size: cover, mask-position: center (with -webkit- prefixed equivalents). The masked copies only show the photo through the silhouette shape.
.banner-header:position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 75%; text-align: center; color: #e3e3db; z-index: 2;(light text over the photo)..banner-intro-text-container:position: absolute; top: 50%; transform: translateY(-50%); width: 100%; display: flex; gap: 0.5rem; z-index: 10;..banner-intro-text:flex: 1; position: relative; will-change: transform;. The first one getsdisplay: flex; justify-content: flex-end;so the two words ("Surface" / "Layered") sit side by side at the exact horizontal center of the viewport, separated only by the 0.5rem gap.- Media query
@media (max-width: 1000px):.hero h1,.outro h1and.banner-headerbecomewidth: calc(100% - 4rem);.
GSAP effect (exhaustive)
Everything runs inside a DOMContentLoaded listener.
Setup
gsap.registerPlugin(ScrollTrigger, SplitText).- Lenis wiring (standard pattern):
`` const lenis = new Lenis(); lenis.on("scroll", ScrollTrigger.update); gsap.ticker.add((time) => { lenis.raf(time * 1000); }); gsap.ticker.lagSmoothing(0); ``
- Grab references:
bannerContainer=.banner-img-containerbannerIntroTextElements=gsap.utils.toArray(".banner-intro-text")(2 elements)bannerMaskLayers=gsap.utils.toArray(".mask")(the 6 masked layers, in DOM order)bannerHeader=.banner-header h1- SplitText:
new SplitText(bannerHeader, { type: "words" }); keepsplitText.words. Immediatelygsap.set(words, { opacity: 0 }). - Initial states:
- Each mask layer
i(0-based over the 6 masked layers):gsap.set(layer, { scale: 0.9 - i * 0.2 })→ scales 0.9, 0.7, 0.5, 0.3, 0.1, −0.1. (Yes, the last value is negative — keep the formula exactly; it's what creates the deepest layer popping in late.) gsap.set(bannerContainer, { scale: 0 })— the whole banner starts collapsed to a point.
ScrollTrigger
One single ScrollTrigger.create({...}) — no tweens, no timeline. All motion is applied imperatively with gsap.set inside onUpdate from self.progress (a scrubbed progress value 0→1):
trigger: ".banner",
start: "top top",
end: `+=${window.innerHeight * 4}px`, // pinned for 4 viewport heights
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate logic (per frame, progress = self.progress)
- Container zoom:
gsap.set(bannerContainer, { scale: progress })— linear 0 → 1 across the whole pin distance. - Telescoping mask layers: for each masked layer
i: initialScale = 0.9 - i * 0.2layerProgress = Math.min(progress / 0.9, 1.0)(layers finish converging at 90% of the scrub)currentScale = initialScale + layerProgress * (1.0 - initialScale)gsap.set(layer, { scale: currentScale })- Net effect: every layer interpolates linearly from its staggered initial scale to exactly 1.0 by
progress = 0.9, so the silhouette copies converge onto the base image. - Intro words slide apart (only while
progress <= 0.9): textProgress = progress / 0.9moveDistance = window.innerWidth * 0.5- first intro text:
gsap.set(el, { x: -textProgress * moveDistance })(moves left, off-screen) - second intro text:
gsap.set(el, { x: textProgress * moveDistance })(moves right, off-screen) - Headline word-by-word reveal (window
0.7 <= progress <= 0.9): headerProgress = (progress - 0.7) / 0.2(normalized 0→1 inside the window)- For each word
ioftotalWords: wordStartDelay = i / totalWords,wordEndDelay = (i + 1) / totalWords- if
headerProgress >= wordEndDelay→ opacity 1 - else if
headerProgress >= wordStartDelay→ opacity =(headerProgress - wordStartDelay) / (wordEndDelay - wordStartDelay)(linear fade within the word's own slice) - else opacity 0
- Apply with
gsap.set(word, { opacity: ... })— a sequential left-to-right cascade fully driven by scroll. - Guard rails: if
progress < 0.7set all words to opacity 0; ifprogress > 0.9set all words to opacity 1 (so scrolling backwards/forwards past the window snaps correctly).
There are no eases, durations, delays or staggers anywhere — every value is a direct linear function of scroll progress (the only smoothing comes from scrub: 1 and Lenis inertia).
Assets / images
- 1 editorial photo (JPG): a full-bleed studio portrait of a single person against a bold solid-color backdrop, roughly 4:5/portrait framing, displayed with
object-fit: coverfilling the viewport. This same file is used in all 7 image layers. - 1 silhouette alpha mask (PNG): the silhouette of the same figure (head, hair, shoulders, arms) as a solid opaque shape on a fully transparent background. It is applied via CSS
mask-imagewithmask-size: cover; mask-position: center, so only the silhouette area of the masked photo copies is visible. For the effect to read well, the silhouette should roughly match the subject's pose/position in the photo.
Behavior notes
- The banner is pinned for 4 extra viewport heights (
pinSpacing: true), so total page scroll ≈ hero (100svh) + banner pin (100svh + 4×innerHeight) + outro (100svh). - Scrolling back up fully reverses everything (all state is derived from
progress). will-change: transformon the container, layers, images and intro texts keeps the constantgsap.setscaling smooth.endandmoveDistancereadwindow.innerHeight/window.innerWidthonce at creation; no resize handling is required.- Works on mobile (only the text widths change under 1000px).