All components

PrototypeStudio Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Pinned spotlight section scrubbed over five viewport heights with Lenis smooth scroll: a 01/10 counter travels down the section while a centered column of ten project images translates upward, the image crossing the viewport midline brightens to full opacity, and each project name slides up in its own progress window and turns white while active.

How it's built

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

scroll pin scrub lenis spotlight counter portfolio images

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

PrototypeStudio Scroll Animation — Pinned Spotlight Counter + Image Column

Goal

Build a portfolio "spotlight" section that gets pinned and scrubbed over five viewport heights while three things move in lockstep with scroll progress: a large uppercase 01/10 counter that both updates its number and slides straight down the left edge; a vertical column of ten project images, centered on screen, that translates upward so each image passes through the middle of the viewport in turn; and a bottom-right list of ten project names where each name slides up within its own slice of the scroll and turns white while it is the active project. The image currently crossing the horizontal midline brightens from 50% to full opacity. Smooth scroll via Lenis. There is a plain intro screen before and a plain outro screen after. This is not a GSAP timeline — it is a single pinned ScrollTrigger whose onUpdate callback drives everything with gsap.set off self.progress.

Tech

Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:

  • gsap (3.x) plus the plugin ScrollTrigger.
  • lenis — smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

Run everything inside document.addEventListener("DOMContentLoaded", …). No other plugins, no SplitText, no CustomEase, no Three.js.

Layout / HTML

Three full-screen <section>s in order: .intro, .spotlight, .outro. Class names are load-bearing — the JS/CSS query them.

<section class="intro">
  <p>A collection of selected works</p>
</section>

<section class="spotlight">
  <div class="project-index">
    <h1>01/10</h1>
  </div>

  <div class="project-images">
    <div class="project-img"><img src="…" alt="" /></div>
    <div class="project-img"><img src="…" alt="" /></div>
    <!-- …ten .project-img wrappers total, each with one <img> -->
  </div>

  <div class="project-names">
    <p>Human Form Study</p>
    <p>Interior Light</p>
    <p>Project 21</p>
    <p>Shadow Portraits</p>
    <p>Everyday Objects</p>
    <p>Unit 07 Care</p>
    <p>Motion Practice</p>
    <p>Noonlight Series</p>
    <p>Material Stillness</p>
    <p>Quiet Walk</p>
  </div>
</section>

<section class="outro">
  <p>Scroll complete</p>
</section>

<script type="module" src="./script.js"></script>

Notes:

  • .project-images holds exactly 10 .project-img wrappers (each one <img>). .project-names holds exactly 10 <p>. The two counts must match — the counter, the name windows and the image column are all keyed to totalProjectCount = 10.
  • The counter starts as literal text 01/10; JS overwrites its textContent every frame.
  • Use the neutral demo copy verbatim. "PrototypeStudio" is the fictional demo brand — no real client names. Intro paragraph: A collection of selected works. Outro paragraph: Scroll complete. The ten project names above are arbitrary editorial titles.

Styling

Font (Google Fonts): Google Sans — import the full ital/optical-size/weight axis:

@import url("https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap");

Palette (exact hex):

  • Page background: #141414 (near-black).
  • Text: #fff.
  • Inactive project name: #4a4a4a (dim grey). Active name animates to #fff.

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { font-family:"Google Sans", sans-serif; background-color:#141414; color:#fff; }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { text-transform:uppercase; font-size:clamp(3rem, 5vw, 7rem); font-weight:400; line-height:1; }
  • p { font-size:1.5rem; font-weight:500; line-height:1.25; }

Sections:

  • section { position:relative; width:100%; height:100svh; padding:2rem; overflow:hidden; } — every section is exactly one viewport tall with a 2rem padding (this padding value is read by the JS, so keep it at 2rem).
  • .intro, .outro { display:flex; justify-content:center; align-items:center; text-align:center; } — a single centered line of copy.

.project-index (the counter) — no special positioning of its own; it sits in the normal flow at the top-left of the padded .spotlight. Its h1 is the element GSAP moves.

.project-images (the centered image column):

  • position:absolute; top:0; left:50%; transform:translateX(-50%); — horizontally centered.
  • width:35%; — the column is 35% of viewport width; each image is 16:9 within it.
  • padding:50svh 0;half-a-viewport of top and bottom padding (this is what lets the first/last images sit near mid-screen at the extremes of scroll).
  • display:flex; flex-direction:column; gap:0.5rem;
  • z-index:-1; — the column sits behind the counter and names.

.project-img:

  • width:100%; aspect-ratio:16/9; overflow:hidden;
  • opacity:0.5;dimmed by default.
  • transition:all 0.3s ease; — so the JS-driven opacity flip eases smoothly.

.project-names (bottom-right name list):

  • position:absolute; right:2rem; bottom:2rem; display:flex; flex-direction:column; align-items:flex-end; — right-aligned stack anchored to the bottom-right corner.
  • .project-names p { color:#4a4a4a; transition:color 0.3s ease; } — dim by default, eased color change.

Performance hint (keep it): .project-index h1, .project-images, .project-names p { will-change:transform; }.

GSAP effect (the important part — be exhaustive)

Lenis ↔ GSAP wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Grab elements + measure geometry (all measured once, at load)
const spotlightSection        = document.querySelector(".spotlight");
const projectIndex            = document.querySelector(".project-index h1");
const projectImgs             = document.querySelectorAll(".project-img");        // 10
const projectImagesContainer  = document.querySelector(".project-images");
const projectNames            = document.querySelectorAll(".project-names p");    // 10
const projectNamesContainer   = document.querySelector(".project-names");
const totalProjectCount       = projectNames.length;                             // 10

const spotlightSectionHeight  = spotlightSection.offsetHeight;                   // ≈ 100svh
const spotlightSectionPadding = parseFloat(getComputedStyle(spotlightSection).padding); // 2rem → 32
const projectIndexHeight      = projectIndex.offsetHeight;                       // counter h1 height
const containerHeight         = projectNamesContainer.offsetHeight;             // name-list height
const imagesHeight            = projectImagesContainer.offsetHeight;            // tall image column (10 imgs + gaps + 100svh padding)

// Travel distances:
const moveDistanceIndex  = spotlightSectionHeight - spotlightSectionPadding * 2 - projectIndexHeight;  // + (counter slides DOWN)
const moveDistanceNames  = spotlightSectionHeight - spotlightSectionPadding * 2 - containerHeight;     // + (names slide UP by -value)
const moveDistanceImages = window.innerHeight - imagesHeight;                                          // − (column slides UP)

const imgActivationThreshold = window.innerHeight / 2;  // the horizontal midline of the viewport
  • moveDistanceIndex is the vertical span from the top of the padded content box to where the counter's bottom edge reaches the bottom of that box → the counter travels down by this many px across the full scroll.
  • moveDistanceNames is the analogous span for the name list → each name travels up by up to this much (applied negatively).
  • moveDistanceImages is innerHeight − imagesHeight, a negative number (the column is far taller than the viewport), so multiplying by progress translates the column upward.
The single pinned ScrollTrigger (no timeline — everything in onUpdate)
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 5}px`,   // pinned/scrubbed across FIVE viewport heights
  pin: true,
  pinSpacing: true,
  scrub: 1,                                // 1s catch-up smoothing on the scrub
  onUpdate: (self) => {
    const progress = self.progress;        // 0 → 1 over the 5vh runway

    // 1) COUNTER TEXT — which project index are we on (1-based, clamped to 10)
    const currentIndex = Math.min(
      Math.floor(progress * totalProjectCount) + 1,
      totalProjectCount,
    );
    projectIndex.textContent =
      `${String(currentIndex).padStart(2, "0")}/${String(totalProjectCount).padStart(2, "0")}`;
    // → "01/10", "02/10", … "10/10"

    // 2) COUNTER POSITION — slide the h1 straight down
    gsap.set(projectIndex, { y: progress * moveDistanceIndex });

    // 3) IMAGE COLUMN — translate the whole column upward
    gsap.set(projectImagesContainer, { y: progress * moveDistanceImages });

    // 4) IMAGE OPACITY — brighten whichever image is crossing the midline
    projectImgs.forEach((img) => {
      const r = img.getBoundingClientRect();
      if (r.top <= imgActivationThreshold && r.bottom >= imgActivationThreshold) {
        gsap.set(img, { opacity: 1 });     // the image spanning the viewport's vertical center
      } else {
        gsap.set(img, { opacity: 0.5 });   // everything else stays dimmed
      }
    });

    // 5) PROJECT NAMES — each name owns a 1/10 slice of progress
    projectNames.forEach((p, index) => {
      const startProgress = index / totalProjectCount;
      const endProgress   = (index + 1) / totalProjectCount;
      const projectProgress = Math.max(
        0,
        Math.min(1, (progress - startProgress) / (endProgress - startProgress)),
      );

      gsap.set(p, { y: -projectProgress * moveDistanceNames });  // slide UP within its window

      if (projectProgress > 0 && projectProgress < 1) {
        gsap.set(p, { color: "#fff" });    // active → white
      } else {
        gsap.set(p, { color: "#4a4a4a" }); // idle → dim grey
      }
    });
  },
});

Precise behavior of each sub-effect:

  • Counter numberMath.floor(progress * 10) + 1, capped at 10. It steps 01/10 → 10/10 as you scrub; the /10 denominator is totalProjectCount zero-padded. It only reads 10/10 at the very end (the Math.min clamp).
  • Counter slide — a simple linear y = progress * moveDistanceIndex (a gsap.set, so it tracks the scrub 1:1). Moves the big 01/10 from the top of the padded box down to the bottom.
  • Image columny = progress * moveDistanceImages (moveDistanceImages is negative), so the tall centered column glides upward the whole scroll. Because of the 50svh top/bottom padding, at progress ≈ 0 the first image sits near the vertical center and at progress ≈ 1 the last image sits near the vertical center; in between, each of the ten images passes through the midline in sequence.
  • Image activation — every frame, each image's live getBoundingClientRect() is tested: if the viewport midline (innerHeight/2) falls between the image's top and bottom, that image is set to opacity:1, all others to opacity:0.5. Combined with the CSS transition:all 0.3s ease, the "spotlight" image brightens as it reaches center and dims as it leaves. Exactly one image is typically full-opacity at a time.
  • Name windows — the ten names divide the 0→1 progress into ten equal slices. Name index is "active" only while progress is inside [index/10, (index+1)/10); within that slice its local projectProgress ramps 0→1. It uses that local ramp to (a) slide up by -projectProgress * moveDistanceNames and (b) hold color #fff. Outside its slice projectProgress clamps to 0 or 1, the name returns to #4a4a4a, and its y sits at 0 (before its slice) or -moveDistanceNames (after its slice — i.e. it stays parked up). The CSS transition:color 0.3s ease softens the grey↔white flip.

No ease/duration/stagger anywhere — every motion is a gsap.set inside onUpdate, so the *only* easing/smoothing is (a) ScrollTrigger's scrub: 1 on the driving progress and (b) the two CSS transitions (0.3s ease on image opacity and name color). No gsap.to/gsap.timeline is used. No SplitText, CustomEase, lerp/rAF interpolation, or Three.js.

Assets / images

Ten landscape 16:9 photos, each rendered 100% width/height with object-fit:cover inside a column that is 35% of viewport width — so they read as a vertical portfolio strip. Source them roughly landscape 16:9; the exact crop doesn't matter (cover handles it). The set is not one uniform color story: the outdoor portraits and the interior are warm and sun-lit (golden-hour light, beige/tan/cream neutrals, pops of coral and orange), while two of the cosmetic still-lifes are cool and moody (grey or near-black backdrops). The mix is roughly: three lifestyle portrait subjects (two of them repeated as a second near-identical crop, so five portrait frames total), one warm minimal interior, and four product/cosmetic still-lifes. Real subjects in the current set (by role, no brands or logos anywhere):

  • Portrait — man in coral tank (appears twice, two crops): a young man with curly brown hair in a coral/peach tank top, leaning against a pale sage-green wall that catches his sharp cast shadow in warm sunlight; pale stucco building and a potted plant behind him.
  • Portrait — backlit woman (appears twice, two crops): a smiling woman with dark curly hair haloed by a low golden-hour sun, wearing white wired earphones, in a grey tank; amber, cream and green foliage tones.
  • Portrait — orange jacket: a man in a vivid orange bomber jacket standing in a sunlit courtyard, pale stucco buildings and a small tree behind him under a bright washed-out sky.
  • Interior: a serene warm minimal room — a wooden-framed armchair and a potted plant by a tall window, warm grid-shaped light and shadow raking across a beige wall and pale wood floor.
  • Still-life — moody grey: a red lipstick with a gold band, a glossy black jar, and a clear glass pump bottle, lit against a cool grey concrete backdrop.
  • Still-life — warm blush: a frosted glass cream jar with a white lid, a rose-gold/mauve lipstick, and a frosted pump bottle, with a blush rose and petals on a cream surface; soft pink and beige tones.
  • Still-life — dried flowers: a glossy white cosmetic bottle with a black cap resting on a stone, surrounded by dried beige gypsophila against a neutral taupe backdrop.
  • Still-life — dark chrome: an open silver/chrome powder compact showing a nude beige powder pan and its mirror, on a near-black backdrop.

Behavior notes

  • Trigger: scroll only. Nothing autoplays. The whole spotlight is scrubbed (scrub: 1) — it plays forward on scroll-down and reverses on scroll-up; parking the scroll freezes it mid-sequence.
  • Pin runway: the .spotlight section is pinned with pinSpacing: true for += window.innerHeight * 5 px, so ScrollTrigger inserts a 5-viewport spacer automatically — no manual margin needed. The intro sits above and the outro below in normal flow.
  • Heights use svh (100svh sections, 50svh column padding) so mobile browser chrome doesn't break the full-screen layout.
  • Responsive (@media (max-width:1000px)): the image column relaxes to width: calc(100% - 4rem) and gap: 25svh (near-full-width images spaced far apart), and every project name is forced white (.project-names p { color:#fff !important; }) since the dim/active distinction is less useful at that size. The pin + scrub effect still runs.
  • Keep the will-change:transform hints on the counter h1, the image column and the name paragraphs.
  • No reduced-motion handling in the original.