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.