Editorial Portfolio Gallery — Fixed Project Number that Sticks at Center then Peels Upward as the Next Section Pushes it Out
Goal
Build a tall, scroll-driven vertical portfolio. Eight full-viewport project sections scroll past under smooth (Lenis) scrolling. On the left edge of each section a large two-digit project number (01–08) sits pinned; when a section reaches the vertical center of the screen its number switches from absolutely-positioned to position:fixed at top:50vh so it hangs at screen-center, and the moment the next section climbs into view the number peels upward out of a fixed-height mask — the mask, an inner digit-wrapper, and each of the two digit glyphs all translate y:-80 with staggered durations and delays so the number exits in layers. Meanwhile a slim right-edge progress bar fills top→bottom, a small project-name list on the left has a triangular indicator that hops down one row per section while the current name goes black, and a bottom-right preview thumbnail swaps to whichever gallery image is currently crossing the viewport's vertical center. The star effect is the layered, velocity-aware masked-number push-up driven entirely from a single per-frame ScrollTrigger.onUpdate reading getBoundingClientRect().
Tech
Vanilla HTML/CSS/JS with ES module imports, fresh Vite project. Install and import from npm:
gsap(3.x) plus the pluginScrollTriggeronly.lenis— smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
Wrap the whole script in document.addEventListener("DOMContentLoaded", () => { … }). No SplitText, no CustomEase, no canvas/WebGL. The digit-splitting and the image-grid population are done by hand in JS (see below), so the HTML you author does not contain the digit spans or the gallery <img>s — JS injects them.
Layout / HTML
Class names are load-bearing (JS/CSS query them). Author the gallery .images containers empty — JS fills them.
<nav>
<a href="#">Index</a>
<a href="#">Watch Showreel</a>
</nav>
<div class="nav-items">
<a href="#">Work,</a>
<a href="#">About,</a>
<a href="#">Contact</a>
</div>
<div class="whitespace w-1"></div>
<div class="gallery">
<!-- Repeat this .project block 8 times, numbers 01 → 08 -->
<div class="project">
<div class="index">
<div class="mask"><h1>01</h1></div>
</div>
<div class="images"></div> <!-- left empty; JS injects 6 .img each -->
</div>
<!-- …02 … 08 … -->
</div>
<div class="whitespace w-2"></div>
<div class="project-names">
<div class="indicator">
<div class="symbol"></div>
</div>
<div class="name active"><p>Atlas Studio</p></div>
<div class="name"><p>Nimbus</p></div>
<div class="name"><p>Solara</p></div>
<div class="name"><p>Quantum</p></div>
<div class="name"><p>Echo Labs</p></div>
<div class="name"><p>Vesper</p></div>
<div class="name"><p>Axon</p></div>
<div class="name"><p>Horizon</p></div>
</div>
<div class="preview-img">
<img src="/img1.jpg" alt="" />
</div>
<div class="progress-bar"></div>
<div class="footer"><p>Portfolio 2024</p></div>
<script type="module" src="./script.js"></script>
Notes:
- Exactly 8
.projectblocks; each.mask h1holds a two-character number as text (01…08) — the split logic assumes exactly two glyphs. - There are 8
.namerows matching the 8 projects; the first row carries.active. Names are neutral invented studio names (no real brands). - The
.preview-img's initial<img>src is the first gallery image; JS will overwrite.srcon scroll.
Styling
Font: Circular Std, weight 500, applied to everything. (Circular Std is a licensed geometric sans; if unavailable, fall back to any clean geometric sans such as system-ui/Helvetica/Inter — the layout does not depend on the exact face.) Everything is black text on a plain white page (no background set on body, so default white #fff).
Reset & type:
::-webkit-scrollbar { display:none; }(hide native scrollbar).* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }h1 { font-family:"Circular Std"; font-weight:500; font-size:58px; }p, a { text-decoration:none; text-transform:uppercase; font-family:"Circular Std"; font-size:14px; font-weight:500; color:#000; transition:color 0.3s; }
Fixed chrome (all corners pinned to the viewport):
nav { position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; }— "Index" left, "Watch Showreel" right..nav-items { position:fixed; top:2em; left:40%; display:flex; gap:0.25em; }— three inline links.nav a, .nav-items a { text-transform:none; }(override the global uppercase for the nav links only)..footer p { position:fixed; bottom:2em; left:40%; text-transform:none; }
A left:40% vertical guide aligns .nav-items, .project-names, and the footer to the same column.
Spacers (pure scroll runway so the first/last project can travel through center):
.whitespace.w-1 { width:100vw; height:50vh; }.whitespace.w-2 { width:100vw; height:45vh; }
Gallery grid:
.gallery { position:relative; width:100%; display:flex; flex-direction:column; gap:20em; }— big 20em vertical gap between projects..project { position:relative; width:100vw; height:100vh; display:flex; }— each section is one full viewport, a flex row..index { flex:1; padding-left:2em; height:0; }— left rail, 1/7 of the row width, zero height (it only anchors the absolutely-positioned mask; it must not consume vertical space)..images { flex:6; display:flex; flex-direction:column; gap:1em; height:100vh; }— right column, 6/7 width, full height, holding 6 stacked images with 1em gaps..img { flex:1; width:200px; background-color:lightgray; overflow:hidden; }— each image cell splits the column height equally (flex:1), clips overflow..img img { opacity:0.9; }— gallery images sit at 90% opacity.
The masked number (critical geometry):
.index .mask { position:absolute; top:0; left:2em; height:70px; overflow:hidden; will-change:transform; }— the mask is a 70px-tall clipping window at the top-left of each.project(its offset parent is.project, which isrelative).overflow:hiddenis what makes the digit disappear as it slides up..index .mask h1 { position:relative; will-change:transform; }.index .mask h1 span { position:relative; display:inline-block; will-change:transform; }.digit-wrapper, .digit-wrapper span { display:inline-block; position:relative; will-change:transform; }— the JS-injected wrapper and its two digit spans are inline-blocks so each can be transformed independently.
Left project-name list + indicator:
.project-names { position:fixed; width:200px; top:50vh; left:40%; transform:translateX(0%); }— fixed at vertical center, left column..indicator { position:absolute; top:0; right:0; width:18px; height:18px; display:flex; justify-content:center; align-items:center; will-change:transform; }— a small box at the right of the name list that GSAP slides down..symbol { width:12px; height:12px; background-color:#000; clip-path:polygon(0 50%, 100% 100%, 100% 0); }— a left-pointing black triangle..name { height:18px; }— each row is exactly 18px tall (matches the indicator's 18px step)..name p { color:gray; }and.name.active p { color:#000; }— inactive names are gray, the active one is black.
Bottom-right preview + progress bar:
.preview-img { position:fixed; bottom:2em; right:2em; max-width:40vw; height:calc(50vh - 2em); opacity:0.9; }— a floating preview panel bottom-right, up to 40vw wide, roughly half-viewport tall, at 90% opacity..progress-bar { position:fixed; top:0; right:0; width:8px; height:100vh; background-color:#000; transform-origin:top; transform:scaleY(0); }— an 8px black bar on the far right, scaled from the top, starting collapsed (scaleY(0)).
GSAP effect (be exhaustive — this is the point)
Smooth-scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 750);
});
gsap.ticker.lagSmoothing(0);
Note the multiplier is **time * 750** (not the usual 1000) — a deliberately slower, heavier Lenis feel. Lenis pumps ScrollTrigger.update on every scroll; lag smoothing off.
Up-front state (declared before any ScrollTrigger to dodge a TDZ)
A pinned trigger's onUpdate can fire during ScrollTrigger.refresh() on creation, before later declarations run, so declare these first:
let scrollVelocity = 0, lastScrollTop = 0, activeIndex = -1;
Manual digit split — splitTextIntoSpans(".mask h1")
For every .mask h1, read its two characters and rebuild its innerHTML:
function splitTextIntoSpans(selector) {
document.querySelectorAll(selector).forEach((el) => {
const [firstDigit, secondDigit] = el.innerText; // e.g. "0","1"
el.innerHTML = `
<div class="digit-wrapper">
<span class="first">${firstDigit}</span><span class="second">${secondDigit}</span>
</div>`;
});
}
splitTextIntoSpans(".mask h1");
Result nesting inside each mask: h1 > .digit-wrapper > span.first + span.second. These four layers (mask, .digit-wrapper, .first, .second) are what the push-up animates independently.
Manual gallery population — populateGallery()
const imagesPerProject = 6;
const totalImages = 50;
let imageIndex = 1;
function populateGallery() {
document.querySelectorAll(".images").forEach((container) => {
for (let j = 0; j < imagesPerProject; j++) {
if (imageIndex > totalImages) imageIndex = 1; // wrap/cycle
const cell = document.createElement("div");
cell.classList.add("img");
const img = document.createElement("img");
img.src = `/img${imageIndex}.jpg`;
img.alt = `Project Image ${imageIndex}`;
cell.appendChild(img);
container.appendChild(cell);
imageIndex++;
}
});
}
populateGallery();
Each of the 8 .images gets 6 .img cells → 48 images total, numbered sequentially img1…img48 (the cycle-back at 50 never triggers with these counts). Adjust the /img${i}.jpg path to wherever your fixtures live.
1) Side progress bar
ScrollTrigger.create({
trigger: "body",
start: "top top",
end: "bottom bottom",
onUpdate: (self) => gsap.set(".progress-bar", { scaleY: self.progress }),
});
The 8px right bar's scaleY tracks whole-page scroll progress 0→1 (fills downward from transform-origin:top). Per-frame gsap.set, no ease.
2) Bottom-right preview image follows the centered gallery image
For every .img img create a trigger keyed to the viewport's vertical center:
const previewImg = document.querySelector(".preview-img img");
document.querySelectorAll(".img img").forEach((img) => {
ScrollTrigger.create({
trigger: img,
start: "top 50%",
end: "bottom 50%",
onEnter: () => (previewImg.src = img.src),
onEnterBack: () => (previewImg.src = img.src),
});
});
Whenever a gallery image crosses the 50%-height line (scrolling either direction) the floating preview swaps its src to that image. No tween — an instant source swap.
3) Name-list indicator hop + active name
const indicator = document.querySelector(".indicator");
const indicatorStep = 18; // px per row (= .name height)
const names = gsap.utils.toArray(".name");
gsap.set(".indicator", { top: "0px" });
const projects = gsap.utils.toArray(".project");
projects.forEach((project, index) => {
ScrollTrigger.create({
trigger: project,
start: "top 50%",
end: "bottom 50%",
onEnter: () => {
gsap.to(indicator, { top: Math.max(0, index * indicatorStep) + "px", duration: 0.3, ease: "power2.out" });
names.forEach((n, i) => n.classList.toggle("active", i === index));
},
onLeaveBack: () => {
const target = index - 1 < 0 ? 0 : (index - 1) * indicatorStep;
gsap.to(indicator, { top: target + "px", duration: 0.3, ease: "power2.out" });
names.forEach((n, i) => n.classList.toggle("active", i === (index - 1 < 0 ? 0 : index - 1)));
},
});
});
As each project's top crosses viewport-center the triangle slides to row index (18px per row) over 0.3s power2.out and that name row becomes black/active; scrolling back up returns it to the previous row.
4) The star: fixed-then-peel masked number (per-frame, velocity-aware)
For every project, cache its four layers, reset transforms, and build one big onUpdate trigger:
projects.forEach((project, i) => {
const mask = project.querySelector(".mask");
const digitWrapper = project.querySelector(".digit-wrapper");
const firstDigit = project.querySelector(".first");
const secondDigit = project.querySelector(".second");
gsap.set([mask, digitWrapper, firstDigit, secondDigit], { y: 0 });
gsap.set(mask, { position: "absolute", top: 0 });
ScrollTrigger.create({
trigger: project,
start: "top bottom",
end: "bottom top",
anticipatePin: 1,
fastScrollEnd: true,
preventOverlaps: true,
onUpdate: (self) => {
const projectRect = project.getBoundingClientRect();
const windowCenter = window.innerHeight / 2;
const nextProject = projects[i + 1];
const velocityAdjustment = Math.min(scrollVelocity * 0.1, 100);
const pushPoint = window.innerHeight * (0.85 + velocityAdjustment / window.innerHeight);
if (projectRect.top <= windowCenter) {
// (a) once this project reaches center, pin its number to screen-center
if (!mask.isFixed) {
mask.isFixed = true;
gsap.set(mask, { position: "fixed", top: "50vh" });
}
// (b) when the NEXT project climbs to the push point, peel this number up
if (nextProject) {
const nextRect = nextProject.getBoundingClientRect();
if (nextRect.top <= pushPoint && activeIndex !== i + 1) {
gsap.killTweensOf([mask, digitWrapper, firstDigit, secondDigit]);
activeIndex = i + 1;
gsap.to(mask, { y: -80, duration: 0.3, ease: "power2.out", overwrite: true });
gsap.to(digitWrapper,{ y: -80, duration: 0.5, delay: 0.5, ease: "power2.out", overwrite: true });
gsap.to(firstDigit, { y: -80, duration: 0.75, ease: "power2.out", overwrite: true });
gsap.to(secondDigit, { y: -80, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
}
}
} else {
// above center → keep number in its normal absolute spot
mask.isFixed = false;
gsap.set(mask, { position: "absolute", top: 0 });
}
// (c) scrolling UP and this project's top is back below center → restore the PREVIOUS number
if (self.direction === -1 && projectRect.top > windowCenter) {
mask.isFixed = false;
gsap.set(mask, { position: "absolute", top: 0 });
if (i > 0 && activeIndex === i) {
const prev = projects[i - 1];
const pMask = prev.querySelector(".mask");
const pWrap = prev.querySelector(".digit-wrapper");
const pFirst = prev.querySelector(".first");
const pSecond = prev.querySelector(".second");
gsap.killTweensOf([pMask, pWrap, pFirst, pSecond]);
activeIndex = i - 1;
gsap.to([pMask, pWrap], { y: 0, duration: 0.3, ease: "power2.out", overwrite: true });
gsap.to(pFirst, { y: 0, duration: 0.75, ease: "power2.out", overwrite: true });
gsap.to(pSecond, { y: 0, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
}
}
},
onEnter: () => { if (i === 0) activeIndex = 0; },
});
});
Precise behavior to reproduce:
- Pin at center: while
projectRect.top <= innerHeight/2, the number's.maskflips toposition:fixed; top:50vh(itsleft:2emis untouched, so it hangs at the middle-left of the screen). A per-element boolean flagmask.isFixedguards the one-time flip. Above center it flips back toposition:absolute; top:0. - Push point is velocity-aware:
pushPoint = innerHeight * (0.85 + velAdj/innerHeight)wherevelAdj = min(scrollVelocity*0.1, 100). At rest the next project must climb to 85% of viewport height to trigger the peel; faster scrolling nudges that threshold lower (later), capped by the +100px clamp.scrollVelocitycomes from the scroll listener below. - The peel (layered stagger, all
y:0 → y:-80, allpower2.out, alloverwrite:true): .mask— duration 0.3, no delay (the clipping window itself lifts first/fastest)..first(first digit) — duration 0.75, no delay..second(second digit) — duration 0.75, delay 0.1 (trails the first digit by 0.1s)..digit-wrapper— duration 0.5, delay 0.5 (starts latest).
These four independent tweens on nested layers compound (each layer's -80 adds to its ancestor's) and, because they have different speeds/delays, the number appears to peel apart and slide up out of the 70px mask in staggered layers rather than as one rigid block. An activeIndex latch (activeIndex !== i + 1) plus killTweensOf ensures the peel fires exactly once per boundary.
- Reverse on scroll-up: when scrolling up (
self.direction === -1) and the current project falls back below center, the previous project's number is animated back toy:0(mask+wrapper 0.3s, first 0.75s, second 0.75s/delay 0.1s) andactiveIndexsteps back toi-1, so numbers reassemble as you scroll upward.
5) Scroll-velocity tracker (feeds the push point)
window.addEventListener("scroll", () => {
const st = window.pageYOffset;
scrollVelocity = Math.abs(st - lastScrollTop);
lastScrollTop = st;
}, { passive: true });
scrollVelocity = absolute px delta per scroll event; larger deltas raise velocityAdjustment (clamped 0–100) which shifts the peel's pushPoint.
Assets / images
A single editorial photography set of 50 images (img1.jpg … img50.jpg); the layout consumes the first 48 (6 per project × 8 projects), plus img1.jpg seeds the preview panel. All are shown with object-fit:cover, so exact source aspect ratios don't matter — provide a mix (the set includes tall 2:3 portraits, near-1:1 frames, and wide ~16:9 / 3:2 landscapes). Mood: quiet, natural-light, film-grain editorial — a weathered older man's face in soft daylight, a freckled red-haired woman close-up, a hands-on-piano-keys low-key interior, a studio side-profile against a saturated red backdrop, and similar character portraits, tight close-ups, and atmospheric lifestyle/environmental shots. Neutral palette overall (skin tones, warm daylight, occasional bold single-color studio backdrop). No logos, no text, no brand marks. If you have fewer than 48 unique photos, repeat them to fill the grid.
Behavior notes
- Trigger: scroll only (Lenis-smoothed). Nothing autoplays; every effect is scroll-position driven via
ScrollTrigger(progress bar and preview are per-frame/threshold; the number peel is discrete tweens fired at boundaries). - The two
.whitespacespacers (50vh top, 45vh bottom) plus the 20em inter-project gaps give the first and last numbers room to reach and leave screen-center. getBoundingClientRect(),window.innerHeight, andwindow.pageYOffsetare read live each frame, so the layout stays correct on resize/viewport changes without recomputing offsets.- Desktop-oriented full-viewport composition; there is no reduced-motion guard and no separate mobile breakpoint in the original — the same triggers run at all sizes.