Inversa Scroll Animation
Goal
Build a single pinned hero that plays a scroll-scrubbed "map framing" sequence driven entirely by one ScrollTrigger.onUpdate handler doing hand-written smoothstep math (no tweens, no timeline). As you scroll four viewport-heights of pinned distance: a column of four stacked text blocks slides up through the viewport; a very tall (200svh) aerial photo parallaxes in the opposite direction (it drifts *down* while the text goes *up* — this counter-motion is the "inversa" idea); a fixed dark layer carrying a subtractive vertical-bar SVG mask scales from 2.5 → 1 to squeeze the photo into a narrow bar-code-shaped window while the photo simultaneously desaturates to grayscale and darkens; and a blueprint grid overlay, two pulsing location markers, and a vertical progress bar fade in during the middle "analysis" phase, then everything reverses — the mask scales back to 2.5, color returns, overlays clear — as the sequence resolves. Smooth scroll via Lenis. A short outro section follows once the pin releases.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework, no Three.js, no SplitText/CustomEase. Plain Vite-style module imports:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Register once at top level: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener.
Layout / HTML
Two <section> elements only — a tall pinned .hero and a short .outro:
<section class="hero">
<div class="hero-img"><img src="…hero-img" alt="" /></div> <!-- 200svh photo, anchored bottom -->
<div class="hero-mask"></div> <!-- fixed dark layer w/ subtractive SVG mask -->
<div class="hero-grid-overlay"><img src="…grid-overlay.svg" alt="" /></div>
<div class="marker marker-1">
<span class="marker-icon"></span>
<p class="marker-label">Anchor Field</p>
</div>
<div class="marker marker-2">
<span class="marker-icon"></span>
<p class="marker-label">Drift Field</p>
</div>
<div class="hero-content"> <!-- 400svh tall, flex column of 4 blocks -->
<div class="hero-content-block">
<div class="hero-content-copy"><h1>Location Framework</h1></div>
</div>
<div class="hero-content-block">
<div class="hero-content-copy"><h2>Coordinate Mapping</h2><p>…</p></div>
</div>
<div class="hero-content-block">
<div class="hero-content-copy"><h2>Active Locations</h2><p>…</p></div>
</div>
<div class="hero-content-block">
<div class="hero-content-copy"><h2>Spatial Center</h2><p>…</p></div>
</div>
</div>
<div class="hero-scroll-progress-bar"></div>
</section>
<section class="outro"><p>The system has reached its final spatial state.</p></section>
Roles / order (DOM order = paint order, no z-index used):
.hero-imgpaints at the back (the photo)..hero-masksits over it (the dark framing layer)..hero-grid-overlay, the two.markers,.hero-content, and the progress bar layer over that..hero-contentis one tall (400svh) flex column holding four full-viewport.hero-content-blocks; each block holds a.hero-content-copy. Block 1 = an<h1>only; blocks 2–4 =<h2>+<p>.- Two markers, each a small dot (
.marker-icon) plus an uppercase mono label (.marker-label). Marker 1 = "Anchor Field", marker 2 = "Drift Field".
Neutral fictional copy (invent nothing brand-related):
- Block 1
h1: "Location Framework" - Block 2
h2: "Coordinate Mapping";p: *"Terrain data is interpreted through directional vectors. Movement responds to relative position rather than absolute distance."* - Block 3
h2: "Active Locations";p: *"Key points are indexed within the field. Each location functions as a reference for spatial alignment and transition logic."* - Block 4
h2: "Spatial Center";p: *"The system converges toward a balanced focal region. Motion decelerates as positional variance reaches equilibrium."* .outro p: "The system has reached its final spatial state."
Styling
Import fonts:
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
Palette (CSS variables on :root):
--light: #fff--dark: #141414--accent-1: #dcdc35(acid yellow — marker 1)--accent-2: #76ef78(mint green — marker 2)
Body: font-family: "DM Sans", sans-serif; background:#141414; color:#fff;. Hide scrollbars (::-webkit-scrollbar { display:none; }).
Global / reset:
* { margin:0; padding:0; box-sizing:border-box; }img { width:100%; height:100%; object-fit:cover; }
Typography:
h1, h2 { font-weight:400; line-height:1.1; }h1 { font-size: clamp(3rem, 4vw, 5rem); }h2 { font-size: clamp(1.5rem, 2.25vw, 3rem); }p { font-size:1.125rem; font-weight:400; line-height:1.4; }
Sections:
section { position:relative; width:100%; height:100svh; background:#141414; overflow:hidden; }.outro { display:flex; justify-content:center; align-items:center; }
The hero layers (this geometry is what the effect hangs on):
.hero-img—position:absolute; bottom:0; width:100%; height:200svh; will-change:transform;and a custom prop--overlay-opacity:0.35. It has a::afterdark scrim: absolutely fills the element,background:#141414; opacity:var(--overlay-opacity); will-change:opacity;. The innerimghaswill-change:filter;. Because the element is 200svh tall and anchoredbottom:0, at rest its top edge sits one viewport-height *above* the top of the screen; positiveytranslate slides it *down* to reveal its upper portion.
.hero-mask—position:fixed; top:0; left:0; width:100%; height:100svh; background:#141414; pointer-events:none; will-change:transform;masked with a two-layer subtractive mask:
``css mask: linear-gradient(#fff, #fff), url("…mask.svg") center/50% no-repeat; -webkit-mask: linear-gradient(#fff, #fff), url("…mask.svg") center/50% no-repeat; mask-composite: subtract; -webkit-mask-composite: subtract; ` The solid gradient layer paints the whole dark rectangle; the SVG bar pattern (centered, sized 50% of the element) is subtracted from it — so the dark layer is punched full of vertical slits shaped like the SVG bars, and the photo shows through those slits while everything else stays dark. Scaling this element (via GSAP scale`) scales the whole masked result, growing/shrinking those slits.
.hero-grid-overlay—position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:55%; will-change:opacity;. Itsimghasopacity:0.25(a faint blueprint grid). Element opacity is driven by GSAP.
.marker—position:absolute; transform:translate(-50%,-50%); display:flex; align-items:center; gap:1rem; will-change:opacity;..marker-1 { top:50svh; left:50vw; }uses--accent-1;.marker-2 { top:35svh; left:60vw; }uses--accent-2..marker-label { text-transform:uppercase; font-family:"DM Mono"; font-size:0.7rem; font-weight:500; padding:0.25rem 0.5rem; border-radius:0.25rem; }— filled with the accent color (marker 1 label text#fff, marker 2 label text#141414)..marker-icon { position:relative; width:0.5rem; height:0.5rem; border-radius:2rem; }filled with the accent color, plus a::beforepulsing ping ring: absolutely centered,width:10rem; height:10rem; border-radius:100%;filled with the accent color, animatedanimation: pulse 1.5s cubic-bezier(0.2,0.6,0.35,1) infinite;. Keyframes:
``css @keyframes pulse { 0% { transform: translate(-50%,-50%) scale(0.25); } 80%,100% { opacity: 0; } } `` (This ping is a pure CSS loop, independent of scroll.)
.hero-content—position:absolute; top:0; left:0; width:100%; height:400svh; display:flex; flex-direction:column; will-change:transform;..hero-content-block { width:100%; height:100svh; padding:4rem; display:flex; }.hero-content-copy { width:35%; display:flex; flex-direction:column; gap:0.25rem; }- Per-block alignment: block 1
align-items:flex-end(copy hugs bottom-left); blocks 2 & 4justify-content:flex-end; align-items:center(copy on the right, vertically centered); block 3align-items:center(copy left, vertically centered).
.hero-scroll-progress-bar—position:absolute; top:50%; right:2rem; transform:translateY(-50%); width:0.1rem; height:10rem; background:rgba(255,255,255,0.2);with a custom prop--progress:0. Its::afteris a white fill:width:100%; height:100%; background:#fff; transform-origin:top; transform:scaleY(var(--progress)); will-change:transform;— fills top→bottom as--progressgoes 0→1.
Responsive @media (max-width:800px): mask SVG sized center/75% instead of 50%; grid overlay width:100%; marker-1 top:52.5svh/left:50vw, marker-2 top:45svh/left:70vw; block padding 1.5rem; copy width:75%; progress bar right:1rem.
GSAP effect (the important part — be exhaustive)
Smooth-scroll wiring (Lenis driven by GSAP ticker)
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update); // keep ScrollTrigger synced to Lenis
gsap.ticker.add((time) => { lenis.raf(time * 1000); }); // Lenis rAF driven by GSAP ticker (ms)
gsap.ticker.lagSmoothing(0); // disable lag smoothing so scrub stays glued
Note autoRaf:false — Lenis does not run its own rAF; the GSAP ticker drives it.
Cache elements & measurements (read once at load)
const heroContent = document.querySelector(".hero-content");
const heroImg = document.querySelector(".hero-img");
const heroImgElement = document.querySelector(".hero-img img");
const heroMask = document.querySelector(".hero-mask");
const heroGridOverlay = document.querySelector(".hero-grid-overlay");
const marker1 = document.querySelector(".marker-1");
const marker2 = document.querySelector(".marker-2");
const progressBar = document.querySelector(".hero-scroll-progress-bar");
const heroContentMovedistance = heroContent.offsetHeight - window.innerHeight; // ≈ 300svh in px
const heroImgMovedistance = heroImg.offsetHeight - window.innerHeight; // ≈ 100svh in px
The smoothstep easing helper (used everywhere below)
const ease = (x) => x * x * (3 - 2 * x); // classic smoothstep, eases both ends of a 0→1 ramp
The single ScrollTrigger (pin + scrub — ALL work happens by hand in onUpdate)
There is no timeline and no tweens. Every property is recomputed each frame from self.progress with gsap.set. Reproduce the exact phase math below; do not substitute .to() tweens.
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: `+=${window.innerHeight * 4}px`, // pinned for 4 viewport-heights of scroll
pin: true,
pinSpacing: true,
scrub: 1, // 1s catch-up smoothing on the scrub
onUpdate: (self) => {
const p = self.progress; // 0 → 1 across the pinned range
/* … all mappings below … */
},
});
Inside onUpdate, in this order:
1) Progress bar — linear, 1:1 with scroll:
gsap.set(progressBar, { "--progress": p });
2) Text column slide-up — linear:
gsap.set(heroContent, { y: -p * heroContentMovedistance });
The 400svh column translates up by up to 300svh, scrolling the four text blocks past the viewport one after another. Strictly linear (no ease).
3) Photo parallax (counter-motion) — 3 phases, smoothstepped:
let imgP;
if (p <= 0.45) imgP = ease(p / 0.45) * 0.65; // 0 → 0.65 (smoothstep)
else if (p <= 0.75) imgP = 0.65; // held
else imgP = 0.65 + ease((p - 0.75) / 0.25) * 0.35; // 0.65 → 1.0 (smoothstep)
gsap.set(heroImg, { y: imgP * heroImgMovedistance }); // positive y → photo drifts DOWN
imgP is a 0→1 fraction of the photo's 100svh of extra travel: it ramps to 0.65 by progress 0.45, holds through 0.75, then completes to 1.0 by progress 1. Because the text moves *up* while the photo moves *down*, the two run opposite directions — the signature "inversa" parallax.
4) Mask scale + photo desaturation + scrim darkening — 5 phases, smoothstepped in the two transitions:
let maskScale, saturation, overlayOpacity;
if (p <= 0.4) { // Phase A — full color, unframed
maskScale = 2.5; saturation = 1; overlayOpacity = 0.35;
} else if (p <= 0.5) { // Transition 1 (0.4→0.5): frame + drain color + darken
const t = ease((p - 0.4) / 0.1);
maskScale = 2.5 - t * 1.5; // 2.5 → 1
saturation = 1 - t; // 1 → 0
overlayOpacity= 0.35 + t * 0.35; // 0.35 → 0.7
} else if (p <= 0.75) { // Phase B — held: tight bar-code frame, grayscale, dark
maskScale = 1; saturation = 0; overlayOpacity = 0.7;
} else if (p <= 0.85) { // Transition 2 (0.75→0.85): unframe + restore color + lighten
const t = ease((p - 0.75) / 0.1);
maskScale = 1 + t * 1.5; // 1 → 2.5
saturation = t; // 0 → 1
overlayOpacity= 0.7 - t * 0.35; // 0.7 → 0.35
} else { // Phase C — back to full color, unframed
maskScale = 2.5; saturation = 1; overlayOpacity = 0.35;
}
gsap.set(heroMask, { scale: maskScale });
gsap.set(heroImgElement, { filter: `saturate(${saturation})` });
gsap.set(heroImg, { "--overlay-opacity": overlayOpacity });
At maskScale 2.5 the subtracted slits are enlarged enough to clear the whole viewport (photo essentially unobscured); at maskScale 1 the slits shrink to their natural centered size, so the dark layer squeezes the photo into a narrow vertical bar-code window in the middle. In lock-step the photo goes from full color to grayscale (saturate(1)→saturate(0)) and its scrim darkens (0.35→0.7), then all three reverse in Transition 2.
5) Grid overlay opacity — fast smoothstep in/out inside the held phase:
let gridO;
if (p <= 0.475) gridO = 0;
else if (p <= 0.5) gridO = ease((p - 0.475) / 0.025); // 0 → 1 (over just 2.5% of scroll)
else if (p <= 0.75) gridO = 1;
else if (p <= 0.775) gridO = 1 - ease((p - 0.75) / 0.025); // 1 → 0
else gridO = 0;
gsap.set(heroGridOverlay, { opacity: gridO });
6) Marker 1 opacity — appears at 0.5, gone by 0.75:
let m1;
if (p <= 0.5) m1 = 0;
else if (p <= 0.525) m1 = ease((p - 0.5) / 0.025); // 0 → 1
else if (p <= 0.7) m1 = 1;
else if (p <= 0.75) m1 = 1 - ease((p - 0.7) / 0.05); // 1 → 0
else m1 = 0;
gsap.set(marker1, { opacity: m1 });
7) Marker 2 opacity — same shape, staggered ~0.05 later on the entrance:
let m2;
if (p <= 0.55) m2 = 0;
else if (p <= 0.575) m2 = ease((p - 0.55) / 0.025); // 0 → 1
else if (p <= 0.7) m2 = 1;
else if (p <= 0.75) m2 = 1 - ease((p - 0.7) / 0.05); // 1 → 0
else m2 = 0;
gsap.set(marker2, { opacity: m2 });
The choreography summary (what a viewer sees across the 4-vh pin)
- 0 → 0.4 — text blocks slide up; photo parallaxes in (down) to 65% of its travel by 0.45; mask held at 2.5 (photo full-frame, full color, light scrim). Intro/reveal.
- 0.4 → 0.5 — the "framing" beat: mask 2.5→1 squeezes the photo into the bar-code window, photo desaturates to grayscale, scrim darkens; the grid overlay pops in (0.475→0.5) and marker 1 begins to appear at 0.5.
- 0.5 → 0.75 — held "analysis / map" state: tight bar-code frame, grayscale + dark photo, grid visible, both markers visible (marker 1 from 0.525, marker 2 from 0.575), photo parallax frozen at 0.65. Markers fade out 0.7→0.75; grid fades out 0.75→0.775.
- 0.75 → 0.85 — reverse framing: mask 1→2.5, color returns to full, scrim lightens; photo parallax resumes 0.65→1.0 (completing by progress 1).
- 0.85 → 1 — resolved: full-color, unframed photo finishing its downward drift; pin releases into the outro.
All easing is either strictly linear (progress bar, text slide) or the hand-written smoothstep on each ramp; the only additional smoothing is scrub:1 + Lenis. No duration, delay, stagger, labels, SplitText, or CustomEase anywhere — the "stagger" between markers is just their different threshold windows.
Assets / images
Three assets:
- Hero photo (
.hero-img > img) — a high-angle aerial of a turquoise-to-teal river carving a wide S-curve through flat, vivid green grassland/steppe, with low rolling hills along the horizon under a pale hazy blue sky. Dominant colors: saturated grass green and bright turquoise/cyan water, with soft pale-blue sky and thin sandy banks. Renderedwidth:100%; height:200svh; object-fit:cover→ a very tall vertical crop (~1:2, portrait), so the frame reads as the river's sinuous curve running top-to-bottom. Role: the layer that parallaxes and is revealed through the bar-code mask, then desaturated/darkened mid-sequence. Any high-angle landscape with a strong sinuous feature (river, road, coastline) works.
- Bar-code mask SVG (
mask.svg, used as the subtractive mask layer — not an<img>) — a set of ~19 vertical bars of *varying widths (≈20–60px) and heights (≈760–900px)*, unevenly spaced across the canvas, all a single flat light-gray fill (#D9D9D9) on a transparent background (viewBox ≈0 0 1550 1050). These bars are what get subtracted to punch the vertical slits. Recreate as inline SVG or a file; exact bar layout is not critical, but keep the "irregular vertical bar-code" character and a single solid fill.
- Blueprint grid SVG (
grid-overlay.svg, an<img>) — a faint technical grid: ~10 evenly-spaced vertical + ~7 horizontal thin white lines (stroke-width:2; opacity:0.5) plus two white rectangle outlines (stroke-width:3; fill:none) as highlight boxes, on a transparent background (viewBox ≈0 0 1902 1111). Role: the HUD/map grid that fades in during the analysis phase (further dimmed to 0.25 by CSS).
No brand marks or logos anywhere.
Behavior notes
- Desktop-first, fully scroll-scrubbed — nothing autoplays except the CSS marker
pulserings; the whole sequence is a pure function of scroll position, so it is exactly reversible (scroll back up and it un-frames). - Measurements (
offsetHeight,window.innerHeight,window.innerHeight * 4) are read once at load; the layout is not re-measured on resize (matches the original). - Section heights use
svhso mobile browser chrome doesn't clip the full-viewport panels; sections areoverflow:hidden. - At
max-width:800pxonly positions/sizes shift (larger mask, full-width grid, repositioned markers, tighter padding, wider copy column) — the effect math is unchanged. - No reduced-motion handling in the original.