All components

Telescope Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

'Afterimage', a pinned spotlight gallery of long-exposure photographs in colored light. Scrolling first splits the 'After / Image' intro text apart and scales a background image up from zero, then scrolls a vertical column of frame titles through a diamond-shaped (telescope) clip-path viewfinder while thumbnails fly along a quadratic bezier arc; the title nearest the viewport center highlights and swaps the full-bleed background image. Driven by a single ScrollTrigger (pin + scrub over 10x viewport height, Lenis-smoothed) whose onUpdate maps scroll progress across phased ranges with gsap.set.

How it's built

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

scroll pin scrub bezier-arc clip-path image-reveal lenis gallery editorial

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 Scroll Animation

Goal

Build a pinned, scroll-driven "spotlight" gallery section. As the user scrolls, two intro words ("Beneath" / "Beyond") split apart horizontally while a full-bleed background image scales up from zero behind them; then a diamond-shaped clip-path "telescope viewfinder" takes over: a vertical column of 10 project titles scrolls up through the viewfinder while small thumbnail images fly across the right half of the screen along a quadratic bezier arc. The title closest to the vertical center of the viewport is highlighted (full opacity) and swaps the full-bleed background image to its matching photo. Everything is driven by a single ScrollTrigger (pin + scrub over 10× viewport height, Lenis-smoothed) whose onUpdate maps self.progress across phased ranges using gsap.set — there is no timeline.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

Wire Lenis to GSAP exactly like this:

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

Layout / HTML

Three full-viewport sections. The middle one is the animated spotlight; the titles column and the flying thumbnails are generated by JS, not written in the HTML:

<body>
  <section class="intro">
    <h1>A curated series of surreal frames.</h1>
  </section>

  <section class="spotlight">
    <div class="spotlight-intro-text-wrapper">
      <div class="spotlight-intro-text"><p>Beneath</p></div>
      <div class="spotlight-intro-text"><p>Beyond</p></div>
    </div>

    <div class="spotlight-bg-img">
      <img src="[gallery image 1]" alt="" />
    </div>

    <div class="spotlight-titles-container">
      <div class="spotlight-titles"></div>
    </div>

    <div class="spotlight-images"></div>

    <div class="spotlight-header">
      <p>Discover</p>
    </div>
  </section>

  <section class="outro">
    <h1>Moments in still motion.</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>

Styling

Global reset * { margin:0; padding:0; box-sizing:border-box; }. Body font: "PP Neue Montreal" (or a similar clean grotesque sans as fallback). img { width:100%; height:100%; object-fit:cover; }. Type scale: h1 { font-size:4rem; font-weight:500; line-height:1; }, p { font-size:1.5rem; font-weight:500; line-height:1; }.

  • section: position:relative; width:100vw; height:100svh; overflow:hidden;
  • .intro, .outro: flex-centered, background-color:#0f0f0f; color:#fff;
  • .spotlight-intro-text-wrapper: position:absolute; width:100%; top:50%; transform:translateY(-50%); display:flex; gap:0.5rem; — each .spotlight-intro-text is flex:1; position:relative; will-change:transform; and the first one gets display:flex; justify-content:flex-end; so the two words butt up against the center seam and split outward symmetrically.
  • .spotlight-bg-img: position:absolute; width:100%; height:100%; overflow:hidden; transform:scale(0); will-change:transform; — its inner img starts at transform:scale(1.5); will-change:transform;. (Container scales 0→1 while the image inside counter-scales 1.5→1: a zoom-reveal.)
  • .spotlight-titles-container: position:absolute; top:0; left:15vw; width:100%; height:100%; overflow:hidden; with the diamond/telescope clip-path (note the svh units inside polygon()):

``css clip-path: polygon( 50svh 0px, 0px 50%, 50svh 100%, 100% calc(100% + 100svh), 100% -100svh ); --before-opacity: 0; --after-opacity: 0; ` Its ::before and ::after are two white edge lines tracing the diamond's left point: content:""; position:absolute; width:100svh; height:2.5px; background:#fff; pointer-events:none; transition:opacity 0.3s ease; z-index:10;. The ::before sits at top:0; left:0; transform:rotate(-45deg) translate(-7rem); opacity:var(--before-opacity); and the ::after at bottom:0; left:0; transform:rotate(45deg) translate(-7rem); opacity:var(--after-opacity);`.

  • .spotlight-titles: position:relative; left:15%; width:75%; height:100%; display:flex; flex-direction:column; gap:5rem; transform:translateY(100%); z-index:2; — its h1s are color:#fff; opacity:0.25; transition:opacity 0.3s ease;.
  • .spotlight-images: position:absolute; top:0; right:0; width:50%; min-width:300px; height:100%; z-index:1; pointer-events:none; — the right-half canvas the thumbnails fly across.
  • .spotlight-img (each thumbnail wrapper): position:absolute; width:200px; height:150px; will-change:transform;
  • .spotlight-header: position:absolute; top:50%; left:10%; transform:translateY(-50%); color:#fff; transition:opacity 0.3s ease; z-index:2; opacity:0; — the "Discover" label.
  • @media (max-width:1000px): h1 { font-size:2rem; }; .intro,.outro { padding:2rem; text-align:center; }; remove the clip-path (clip-path:none) and hide the ::before/::after lines (display:none); .spotlight-titles { left:0; }; .spotlight-header { display:none; }.

JS setup (before the ScrollTrigger)

Config and data
const config = { gap: 0.08, speed: 0.3, arcRadius: 500 };

const spotlightItems = [
  { name: "Silent Arc",  img: /* image 1 */ },
  { name: "Bloom24",     img: /* image 2 */ },
  { name: "Glass Fade",  img: /* image 3 */ },
  { name: "Echo 9",      img: /* image 4 */ },
  { name: "Velvet Loop", img: /* image 5 */ },
  { name: "Field Two",   img: /* image 6 */ },
  { name: "Pale Thread", img: /* image 7 */ },
  { name: "Stillroom",   img: /* image 8 */ },
  { name: "Ghostline",   img: /* image 9 */ },
  { name: "Mono 73",     img: /* image 10 */ },
];

gap is the scroll-progress offset between consecutive flying images; speed is how much progress each image's flight consumes; arcRadius is the horizontal bulge of the bezier arc. These three are interdependent — keep the values exactly.

DOM generation

For each item, in order:

  • Append an <h1> with the item name into .spotlight-titles. Give index 0 an inline style.opacity = "1" (it is the initially active title; the rest inherit the CSS 0.25).
  • Create <div class="spotlight-img"><img src="…" alt=""></div>, append to .spotlight-images, and push the wrapper into an imageElements array.

Track the highlighted title with let currentActiveIndex = 0;.

Arc geometry (computed once, at load, from the window size)
const containerWidth  = window.innerWidth * 0.3;
const containerHeight = window.innerHeight;
const arcStartX = containerWidth - 220;
const arcStartY = -200;
const arcEndY   = containerHeight + 200;
const arcControlPointX = arcStartX + config.arcRadius; // bulges 500px to the right
const arcControlPointY = containerHeight / 2;

Quadratic bezier position for t ∈ [0,1] — note that start X and end X are the same (arcStartX), so the path is a rightward-bulging arc that enters above the viewport and exits below it:

function getBezierPosition(t) {
  const x = (1-t)*(1-t)*arcStartX + 2*(1-t)*t*arcControlPointX + t*t*arcStartX;
  const y = (1-t)*(1-t)*arcStartY + 2*(1-t)*t*arcControlPointY + t*t*arcEndY;
  return { x, y };
}

Per-image progress window (staggers the flights):

function getImgProgressState(index, overallProgress) {
  const startTime = index * config.gap;        // 0, 0.08, 0.16, …
  const endTime   = startTime + config.speed;  // each flight spans 0.3 of switchProgress
  if (overallProgress < startTime) return -1;  // not started
  if (overallProgress > endTime)   return 2;   // finished
  return (overallProgress - startTime) / config.speed; // normalized 0..1
}

Finally hide all thumbnails at load: imageElements.forEach((img) => gsap.set(img, { opacity: 0 }));

GSAP effect (the important part — be exhaustive)

One single ScrollTrigger, no timeline, no tweens — every frame writes absolute state with gsap.set from onUpdate:

ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 10}px`, // 10x viewport of scroll distance
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => { /* phases below, keyed on self.progress */ },
});
Phase 1 — intro split + background zoom-reveal (progress ≤ 0.2)

animationProgress = progress / 0.2 (normalized 0→1).

  • introTextElements[0]x: -animationProgress * (window.innerWidth * 0.6) (slides left, up to 60vw).
  • introTextElements[1]x: +animationProgress * (window.innerWidth * 0.6) (slides right). Both get opacity: 1.
  • .spotlight-bg-imgtransform: scale(${animationProgress}) (container grows 0 → 1).
  • .spotlight-bg-img imgtransform: scale(${1.5 - animationProgress * 0.5}) (inner image counter-shrinks 1.5 → 1, so the photo appears to settle into focus as its frame expands).
  • All thumbnail wrappers: opacity: 0. Header style.opacity = "0". Set --before-opacity: "0" and --after-opacity: "0" on .spotlight-titles-container (via gsap.set with the CSS-variable keys).
Phase 2 — handoff (0.2 < progress ≤ 0.25)
  • Lock .spotlight-bg-img at transform: scale(1) and its img at scale(1).
  • Both intro texts → opacity: 0 (they vanish; the CSS transition is not used here, it's a hard set every frame).
  • Thumbnails stay opacity: 0. Header style.opacity = "1" (fades in via its CSS transition: opacity 0.3s ease). Set --before-opacity: "1", --after-opacity: "1" — the two white diagonal lines of the viewfinder fade in (they also have a 0.3s CSS transition).
Phase 3 — titles scroll + bezier image flights + active swap (0.25 < progress ≤ 0.95)

Keep bg at scale 1, intro texts at opacity 0, header at "1", both line variables at "1". Then:

Titles column: switchProgress = (progress - 0.25) / 0.7 (normalized 0→1 across the phase).

const startPosition  = window.innerHeight;              // column starts fully below the viewport
const targetPosition = -titlesContainer.scrollHeight;    // ends fully above it
const currentY = startPosition - switchProgress * (startPosition - targetPosition);
gsap.set(".spotlight-titles", { transform: `translateY(${currentY}px)` });

Flying thumbnails: for each image index, imageProgress = getImgProgressState(index, switchProgress). If it is < 0 or > 1 set the wrapper to opacity: 0; otherwise:

const pos = getBezierPosition(imageProgress);
gsap.set(img, { x: pos.x - 100, y: pos.y - 75, opacity: 1 }); // -100/-75 centers the 200x150 card

The result: thumbnails appear one after another (staggered by 0.08 of switchProgress), each sweeping down the right half of the screen along the arc — entering from above (y = −200), bulging up to 500px to the right at mid-height, and exiting below (y = viewportHeight + 200) — then blinking out.

Active title detection + background swap (every update in this phase): measure each title with getBoundingClientRect(); the title whose vertical center is closest to window.innerHeight / 2 is the active one. When the closest index changes:

  • previous active <h1>style.opacity = "0.25", new one → style.opacity = "1" (the 0.3s CSS transition makes it a soft crossfade);
  • swap the full-bleed background: document.querySelector(".spotlight-bg-img img").src = spotlightItems[closestIndex].img;
  • update currentActiveIndex.
Phase 4 — exit (progress > 0.95)

Header style.opacity = "0", --before-opacity: "0", --after-opacity: "0" — the viewfinder chrome fades out just before the pin releases and the outro section scrolls in.

Note there is no ease/duration anywhere — all motion is a direct linear mapping of scroll progress through gsap.set, softened only by scrub: 1 (one-second catch-up) and Lenis' smooth scrolling. No SplitText, no CustomEase, no Three.js.

Assets / images

10 photographs, one per gallery item. Each image plays two roles: (a) the full-bleed background (object-fit:cover, fills the viewport when its title is active) and (b) a 200×150 px (4:3 landscape) flying thumbnail (also object-fit:cover). Landscape-leaning images work best. Art direction: a cohesive series of surreal, muted-tone editorial/art-photography frames — dreamlike still lifes, figures and landscapes in desaturated, filmic colors that read well both full-screen and tiny. The first image is also hardcoded as the initial src of .spotlight-bg-img img. Name them sequentially (img_1 … img_10).

Behavior notes

  • The intro and outro sections are plain static screens; only .spotlight pins. Total scroll distance through the pinned section is 10× the viewport height, so the sequence feels slow and cinematic.
  • Scrolling is fully reversible — every phase is a pure function of self.progress, so scrubbing backwards replays everything in reverse (including the background swaps and title highlights).
  • Arc geometry and the ScrollTrigger end are computed from the window size once at load; there is no resize handler in the original.
  • Under 1000px wide the clip-path viewfinder, the diagonal lines and the "Discover" header are removed via CSS; the titles column and flying images still animate.
  • No reduced-motion handling in the original.