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-textisflex:1; position:relative; will-change:transform;and the first one getsdisplay: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 innerimgstarts attransform: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 thesvhunits insidepolygon()):
``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;— itsh1s arecolor:#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/::afterlines (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 inlinestyle.opacity = "1"(it is the initially active title; the rest inherit the CSS0.25). - Create
<div class="spotlight-img"><img src="…" alt=""></div>, append to.spotlight-images, and push the wrapper into animageElementsarray.
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 getopacity: 1..spotlight-bg-img→transform: scale(${animationProgress})(container grows 0 → 1)..spotlight-bg-img img→transform: 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. Headerstyle.opacity = "0". Set--before-opacity: "0"and--after-opacity: "0"on.spotlight-titles-container(viagsap.setwith the CSS-variable keys).
Phase 2 — handoff (0.2 < progress ≤ 0.25)
- Lock
.spotlight-bg-imgattransform: scale(1)and itsimgatscale(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. Headerstyle.opacity = "1"(fades in via its CSStransition: 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
.spotlightpins. 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
endare 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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.