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
Fonts: Inter (--sans) for the small type, Space Grotesk (--display) for the big numbers and titles.
Palette (P1 Canary) — near-black on bone, with one canary accent that only ever appears as a surface (a link's hover fill), never as text:
:root {
--paper: #f4f4f0;
--paper-deep: #e8e8e4;
--ink: #0a0a0a;
--muted: #5f6368;
--gray: #8c8c88;
--line: rgba(10, 10, 10, 0.14);
--accent: #ffe500;
}
body { background-color: var(--paper); }
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: var(--display); font-weight:600; font-size:70px; line-height:70px; letter-spacing:-0.02em; color: var(--ink); font-variant-numeric: tabular-nums; }— the tabular figures matter: the project numbers must not jitter as they peel.p, a { text-decoration:none; font-family: var(--sans); font-size:13.5px; font-weight:500; letter-spacing:0.005em; color: var(--ink); transition: color 0.3s; }, anda:hover { background: var(--accent); }— the canary shows up only as that highlight.
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 — character portraits, tight close-ups and atmospheric environmental frames, held together by restraint rather than by a colour story. They sit on a bone page with near-black type, so frames with a calm mid-tone and one clear subject read best; a single saturated studio backdrop every few projects gives the scroll some punctuation. 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.
Images
This component ships with 50 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/gregorylalle/img1.jpg
https://motionprompts.dev/c/gregorylalle/img10.jpg
https://motionprompts.dev/c/gregorylalle/img11.jpg
https://motionprompts.dev/c/gregorylalle/img12.jpg
https://motionprompts.dev/c/gregorylalle/img13.jpg
https://motionprompts.dev/c/gregorylalle/img14.jpg
… 44 more under https://motionprompts.dev/c/gregorylalle/
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—--paper,--paper-deep,--ink,--muted,--gray,--line,--accent, plus the type variables--sans,--display. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener wrapping this whole file is never called and the effect never runs — no error, no animation, nothing to debug. Delete the listener and put its body directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — Every document.querySelector/querySelectorAll above (.mask h1, .images, .progress-bar, .preview-img img, .indicator, .name, .project, .img img) assumes this component owns the document. Give the component a root ref, render it on the outermost element, and scope every one of those lookups to it. Unscoped selectors are not a style problem here: during the StrictMode remount two copies of the subtree exist for an instant, and an unscoped selector will bind to the one that is on its way out.
*(3) Cleanup* — Wrap every tween and trigger this component creates — the progress-bar updater, the eight preview-swap triggers, the per-project indicator triggers, and the eight per-frame mask/peel triggers — in a gsap.context scoped to the root ref, and revert that context in the cleanup. The context records everything GSAP creates inside it, so one call undoes the tweens, the triggers, and the inline styles GSAP wrote (including the position/top flips the mask trigger applies through gsap.set), in one step:
useEffect(() => {
const ctx = gsap.context(() => {
/* the effect exactly as described above */
}, rootRef);
return () => ctx.revert();
}, []);
Without the revert, the StrictMode remount leaves a second set of eight mask triggers and a second progress-bar trigger on the same elements, both reading the same scroll. Registering the plugin (gsap.registerPlugin) belongs at module scope, not inside the effect.
gsap.ticker.add is not covered by the context: the ticker subscription that drives lenis.raf here is neither a tween nor a trigger, so ctx.revert() leaves it running after the smooth-scroll instance feeding it is gone. Keep the exact function reference passed to gsap.ticker.add and call gsap.ticker.remove on that same reference in the cleanup, alongside lenis.destroy().
Smooth scroll is a document-level resource: there must be exactly one Lenis instance on the page, and it must be destroyed when the component that owns it unmounts. If this component is one section of a larger app, the right move is usually to lift Lenis to the app shell and let this component use the existing instance. If it does own it, create it inside the effect and call destroy() in the cleanup, remove the gsap.ticker.add subscription that feeds it as described above, and tear down the lenis.on("scroll", ScrollTrigger.update) wiring along with it — Lenis does not remove its own listeners on destroy().
*(4) What is specific to this component* — Two more things in this script are neither GSAP- nor Lenis-owned, so neither ctx.revert() nor lenis.destroy() will touch them:
- The scroll-velocity tracker (the
window.addEventListener("scroll", ...)that feedsscrollVelocity, which the mask trigger's per-frameonUpdatereads to compute its push point) is a bare DOM listener. Keep the handler reference and remove it explicitly in the same cleanup, or a StrictMode remount leaves two velocity trackers writing into the closed-over variables of a pass that has already been torn down. splitTextIntoSpansandpopulateGalleryare hand-rolled, not library calls, and they do not behave the same way on a second pass.splitTextIntoSpansis safe to re-run: it replaces each.mask h1'sinnerHTMLwholesale, so a second call rebuilds thedigit-wrapper/.first/.secondstructure fresh from the two visible characters instead of nesting into the previous output.populateGalleryis not safe: it appends six new.imgcells into whatever the.imagescontainer already holds instead of clearing it first, so a second invocation leaves twelve cells per project instead of six — the column layout breaks, and the per-image preview triggers created afterward bind to double the elements. Clear each.imagescontainer (or otherwise guard against a repeat call) before appending, inside the same effect.
Also specific to this component: the mask.isFixed flag that latches the fixed-at-center pin is a plain property written directly onto each .mask element, outside both React state and GSAP's tracked state. The .mask nodes themselves are not recreated between the aborted first effect pass and the second — only the effect body re-runs — so a flag left true by the first pass survives ctx.revert() intact. On the second pass, the per-frame onUpdate's if (!mask.isFixed) guard then sees a stale true and never re-applies the fixed positioning, even though the gsap.set(mask, { position: "absolute", top: 0 }) at setup already reset the inline style back to normal flow. Reset every mask's flag to false explicitly at the top of the effect, before any trigger is created, instead of relying on the inline-style reset to imply it.