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 pluginScrollTrigger.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-imagesholds exactly 10.project-imgwrappers (each one<img>)..project-namesholds exactly 10<p>. The two counts must match — the counter, the name windows and the image column are all keyed tototalProjectCount = 10.- The counter starts as literal text
01/10; JS overwrites itstextContentevery 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 at2rem)..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
moveDistanceIndexis 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.moveDistanceNamesis the analogous span for the name list → each name travels up by up to this much (applied negatively).moveDistanceImagesisinnerHeight − 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 number —
Math.floor(progress * 10) + 1, capped at 10. It steps01/10 → 10/10as you scrub; the/10denominator istotalProjectCountzero-padded. It only reads10/10at the very end (theMath.minclamp). - Counter slide — a simple linear
y = progress * moveDistanceIndex(agsap.set, so it tracks the scrub 1:1). Moves the big01/10from the top of the padded box down to the bottom. - Image column —
y = progress * moveDistanceImages(moveDistanceImages is negative), so the tall centered column glides upward the whole scroll. Because of the50svhtop/bottom padding, atprogress ≈ 0the first image sits near the vertical center and atprogress ≈ 1the 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'stopandbottom, that image is set toopacity:1, all others toopacity:0.5. Combined with the CSStransition: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
indexis "active" only whileprogressis inside[index/10, (index+1)/10); within that slice its localprojectProgressramps 0→1. It uses that local ramp to (a) slide up by-projectProgress * moveDistanceNamesand (b) hold color#fff. Outside its sliceprojectProgressclamps to 0 or 1, the name returns to#4a4a4a, and itsysits at0(before its slice) or-moveDistanceNames(after its slice — i.e. it stays parked up). The CSStransition:color 0.3s easesoftens 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
.spotlightsection is pinned withpinSpacing: truefor+= window.innerHeight * 5px, 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(100svhsections,50svhcolumn padding) so mobile browser chrome doesn't break the full-screen layout. - Responsive (
@media (max-width:1000px)): the image column relaxes towidth: calc(100% - 4rem)andgap: 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:transformhints on the counter h1, the image column and the name paragraphs. - No reduced-motion handling in the original.