Looping preview · motionprompts.dev
The build prompt
From motionprompts.dev22,163 chars
# 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:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
```
Wire Lenis to GSAP exactly like this:
```js
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:
```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 `h1`s 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
```js
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)
```js
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:
```js
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):
```js
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`:
```js
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-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`. 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).
```js
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:
```js
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:
```jsx
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:
```jsx
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.