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.

Images

This component ships with 10 reference assets, 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/telescope-scroll-animation/img_1.jpg
https://motionprompts.dev/c/telescope-scroll-animation/img_10.jpg
https://motionprompts.dev/c/telescope-scroll-animation/img_2.jpg
https://motionprompts.dev/c/telescope-scroll-animation/img_3.jpg
https://motionprompts.dev/c/telescope-scroll-animation/img_4.jpg
https://motionprompts.dev/c/telescope-scroll-animation/img_5.jpg
… 4 more under https://motionprompts.dev/c/telescope-scroll-animation/

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--ink, --paper, --paper-dim, --line, --font. 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 waits for DOMContentLoaded, builds ten titles and ten thumbnail wrappers by looping over spotlightItems, wires a Lenis instance through GSAP's ticker, and drives all four phases of the sequence from a single pinned ScrollTrigger whose onUpdate writes absolute state every tick. None of it ever has to undo itself, because the page it lives on never unmounts. React withdraws that guarantee quietly: the spotlight scrubs correctly on first load, and the damage only shows up on a second mount or a real route change.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that doubling is worse than usual because .spotlight is both the trigger and the pinned element: a second, un-reverted ScrollTrigger.create({ trigger: ".spotlight", pin: true, ... }) leaves behind a second pin-spacer in the DOM, which inflates the page's scrollable height and shifts the start/end of every other ScrollTrigger on the page, not just this one. Layered on top of that, a second pass through the spotlightItems.forEach loop that builds titles and thumbnails via createElement/appendChild leaves twenty <h1>s stacked in .spotlight-titles instead of ten, and a second Lenis instance pumping the same wheel event as the first. None of this reproduces in a production build, because React only double-invokes in development; treat the cleanup below as load-bearing, not optional.

*(1) The entry point* — the whole body is guarded by if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();. That guard exists so the script survives being dropped into a page at any point in its parse; useEffect already runs after the DOM is committed, so the check is dead weight in React. Drop the guard and the listener, and move boot's body — the Lenis/ticker wiring, the spotlightItems loop, the arc-geometry constants, and the ScrollTrigger.create call — directly into a useEffect with an empty dependency array.

*(2) Element lookups* — give the root ref to the <section className="spotlight"> element itself, not a wrapper around it: because this same node is both the ScrollTrigger's trigger and its pin target, attaching the ref one level up would pin the wrong element. Two lookups need more than scoping, though. First, .spotlight-titles and .spotlight-images start empty in the markup and are populated by the spotlightItems.forEach loop — port that loop to a .map() over spotlightItems rendered as JSX (ten <h1> siblings, ten <div className="spotlight-img"> wrappers), and collect each node into a titleRefs/imageRefs array via callback refs, instead of re-running createElement/appendChild inside the effect. The vanilla loop has no guard against running twice; a JSX list does, because React reconciles it against the existing tree instead of appending beside it. Second, document.querySelectorAll(".spotlight-intro-text") returns a two-item NodeList read positionally (introTextElements[0] is "Beneath", which is also the one with justify-content: flex-end so it butts against the center seam; [1] is "Beyond") — replace the indexed lookup with two named refs, beneathRef and beyondRef, so which word slides which direction never depends on DOM order surviving a remount.

*(3) Cleanup* — wrap the ticker/Lenis setup, the spotlightItems loop, and the ScrollTrigger.create call in a gsap.context scoped to the root ref:

useEffect(() => {
  const ctx = gsap.context(() => {
    /* Lenis + ticker wiring, the spotlightItems loop, then ScrollTrigger.create */
  }, rootRef);
  return () => ctx.revert();
}, []);

ctx.revert() covers everything the onUpdate callback writes with gsap.set — the x/opacity on both intro-text elements, the two nested transform: scale(...) writes on .spotlight-bg-img and its img, the x/y/opacity on all ten thumbnail wrappers, the translateY on .spotlight-titles, and the --before-opacity/--after-opacity custom properties on .spotlight-titles-container — plus the pinned ScrollTrigger itself, spacer included. It does not cover the three writes in onUpdate that go through plain DOM mutation instead of gsap.set: the header's style.opacity, each title <h1>'s style.opacity, and the background <img>'s src swap. Route the header through gsap.set(headerRef.current, { opacity: ... }) so it falls under the same context as everything else. The title-opacity and background-swap pair are better solved by not writing to the DOM directly at all: the if (closestIndex !== currentActiveIndex) guard means that pair only changes at most nine times across the whole ten-item sequence, not once per scroll tick like the arc math does, so it is cheap enough to hold as const [activeIndex, setActiveIndex] = useState(0) and let JSX own both effects — each title's inline opacity computed from index === activeIndex, and the background <img src> computed from spotlightItems[activeIndex].img. That removes the manual reset entirely, because unmounting a state-driven React tree needs no cleanup the way a hand-written style.opacity/src write does.

The Tech section above wires Lenis into the ticker with an inline callback — gsap.ticker.add((time) => lenis.raf(time * 1000)) — and that shape has no reference left to remove later. Capture it before adapting:

const onTick = (time) => lenis.raf(time * 1000);
gsap.ticker.add(onTick);
// cleanup, before lenis.destroy():
gsap.ticker.remove(onTick);

lenis.on("scroll", ScrollTrigger.update) needs no separate teardown — it lives on the Lenis instance's own emitter, so lenis.destroy() clears it as a side effect. Lenis is a document-level resource, not this section's alone: if the spotlight is one route among several, lift new Lenis() to the app shell and have this effect subscribe the existing instance to ScrollTrigger.update instead of constructing a second one. A second instance here is worse than the generic case, because both the arc geometry and the pinned ScrollTrigger's end are computed once from window.innerHeight at mount — two Lenis instances disagreeing about scroll position feed two different self.progress values into the same phase math, so the titles column, the bezier-arc thumbnails, and the background swap can each read a different phase at once.