All components

Telescope Image Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned banner scrubbed over four viewport heights with Lenis smooth scroll: the image container scales up from zero while seven stacked silhouette-masked copies of the same photo grow at staggered scales, creating a telescoping lens-in-lens depth effect. Two intro words slide apart horizontally and the centered headline reveals word-by-word, all driven by a single ScrollTrigger onUpdate reading self.progress.

How it's built

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

scroll pin scrub mask scale telescope lenis editorial reveal

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

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>:

  1. <section class="hero"> — a centered <h1> with the text "The frame is only the beginning."
  2. <section class="banner"> — the animated section. Inside:
  3. <div class="banner-img-container"> containing, in this order:
  4. 7 image layers, each <div class="img"><img src="..." alt="" /></div>. All 7 use the same photo. The first layer has only class img (the unmasked base). The remaining 6 have class img mask (silhouette-masked copies).
  5. <div class="banner-header"><h1>The Season Wears Confidence</h1></div> — the headline that reveals word-by-word.
  6. <div class="banner-intro-text-container"> with two children:
  7. <div class="banner-intro-text"><h1>Surface</h1></div>
  8. <div class="banner-intro-text"><h1>Layered</h1></div>
  9. <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 (@import in 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;.
  • .hero and .outro: flex, centered both axes; their h1 is width: 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 gets display: 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 h1 and .banner-header become width: calc(100% - 4rem);.

GSAP effect (exhaustive)

Everything runs inside a DOMContentLoaded listener.

Setup
  1. gsap.registerPlugin(ScrollTrigger, SplitText).
  2. 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); ``

  1. Grab references:
  2. bannerContainer = .banner-img-container
  3. bannerIntroTextElements = gsap.utils.toArray(".banner-intro-text") (2 elements)
  4. bannerMaskLayers = gsap.utils.toArray(".mask") (the 6 masked layers, in DOM order)
  5. bannerHeader = .banner-header h1
  6. SplitText: new SplitText(bannerHeader, { type: "words" }); keep splitText.words. Immediately gsap.set(words, { opacity: 0 }).
  7. Initial states:
  8. 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.)
  9. 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)
  1. Container zoom: gsap.set(bannerContainer, { scale: progress }) — linear 0 → 1 across the whole pin distance.
  2. Telescoping mask layers: for each masked layer i:
  3. initialScale = 0.9 - i * 0.2
  4. layerProgress = Math.min(progress / 0.9, 1.0) (layers finish converging at 90% of the scrub)
  5. currentScale = initialScale + layerProgress * (1.0 - initialScale)
  6. gsap.set(layer, { scale: currentScale })
  7. 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.
  8. Intro words slide apart (only while progress <= 0.9):
  9. textProgress = progress / 0.9
  10. moveDistance = window.innerWidth * 0.5
  11. first intro text: gsap.set(el, { x: -textProgress * moveDistance }) (moves left, off-screen)
  12. second intro text: gsap.set(el, { x: textProgress * moveDistance }) (moves right, off-screen)
  13. Headline word-by-word reveal (window 0.7 <= progress <= 0.9):
  14. headerProgress = (progress - 0.7) / 0.2 (normalized 0→1 inside the window)
  15. For each word i of totalWords:
  16. wordStartDelay = i / totalWords, wordEndDelay = (i + 1) / totalWords
  17. if headerProgress >= wordEndDelay → opacity 1
  18. else if headerProgress >= wordStartDelay → opacity = (headerProgress - wordStartDelay) / (wordEndDelay - wordStartDelay) (linear fade within the word's own slice)
  19. else opacity 0
  20. Apply with gsap.set(word, { opacity: ... }) — a sequential left-to-right cascade fully driven by scroll.
  21. Guard rails: if progress < 0.7 set all words to opacity 0; if progress > 0.9 set 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: cover filling 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-image with mask-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: transform on the container, layers, images and intro texts keeps the constant gsap.set scaling smooth.
  • end and moveDistance read window.innerHeight/window.innerWidth once at creation; no resize handling is required.
  • Works on mobile (only the text widths change under 1000px).