ScrollTrigger Variable-Font Marquee — Weight Blooms as the Strips Drift
Goal
Build an editorial scroll page whose centerpiece is a stack of four horizontal image marquees. As you scroll, each strip of images drifts sideways (alternating rows drift opposite directions), and — interspersed among the thumbnails — big uppercase words are split into individual letters whose variable font-weight is scrubbed from 100 (hairline) to 900 (black) in a staggered wave. Everything is scroll-scrubbed through Lenis smooth scroll, so the text visibly "fattens up" and the strips slide as a direct function of scroll position.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, the split-type package (imported as SplitType, this is NOT GSAP SplitText), and lenis (npm) for smooth scroll. A Vite-style dev server that resolves npm imports is all that's needed.
Layout / HTML
<div class="container">
<section class="hero">
<img src="..." alt="" /> <!-- full-bleed opening image -->
</section>
<section class="about">
<p>Step into a surreal, immersive world where reality and fantasy
intertwine — striking visuals meeting thought-provoking narratives.</p>
</section>
<section class="marquees">
<div class="marquee-container" id="marquee-1">
<div class="marquee">
<div class="item"><img src="..." alt="" /></div>
<div class="item with-text"><h1>Unique</h1></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
</div>
</div>
<div class="marquee-container" id="marquee-2">
<div class="marquee">
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item with-text"><h1>Release</h1></div>
<div class="item"><img src="..." alt="" /></div>
</div>
</div>
<div class="marquee-container" id="marquee-3">
<div class="marquee">
<div class="item"><img src="..." alt="" /></div>
<div class="item with-text"><h1>2500</h1></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
</div>
</div>
<div class="marquee-container" id="marquee-4">
<div class="marquee">
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item"><img src="..." alt="" /></div>
<div class="item with-text"><h1>Rarity</h1></div>
<div class="item"><img src="..." alt="" /></div>
</div>
</div>
</section>
<section class="services">
<p>In this meticulously designed dystopian world you'll find stories
of resilience and intrigue.</p>
</section>
<section class="footer">
<h1>The End</h1>
</section>
</div>
<script type="module" src="./script.js"></script>
Key structural facts the JS/CSS depend on: each .marquee-container has a unique id (marquee-1…marquee-4), holds exactly one .marquee track, and that track holds 5 .item children — four .item with an <img> and one .item.with-text with an <h1> word. The word position rotates per row: row 1 word is 2nd (Unique), row 2 word is 4th (Release), row 3 word is 2nd (2500), row 4 word is 4th (Rarity).
Styling
- Variable font (required for the effect): load a display sans that exposes a full weight axis (100–900) as a single variable file. The original uses "Big Shoulders Display" — available as a variable webfont on Google Fonts, or self-hosted via
@font-facewith a.ttf/.woff2weight-variable file. Apply it tohtml, bodyas the globalfont-family. The weight axis MUST be continuous, otherwise the fontWeight tween won't interpolate. - Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. html, body:width: 100%; height: 100%; overflow-x: hidden;plus the variable font-family.img { width: 100%; height: 100%; object-fit: cover; }.- Palette: warm olive-tan
#87795f, near-black#13120e, pure white#fff. p:font-size: 42px; font-weight: 500; text-transform: uppercase;.- Every
section:width: 100%; height: 100vh;. section.about,section.services:padding: 4em; background: #87795f;.section.footer: flex-centered,background-color: #13120e; color: #87795f;. Itsh1:font-size: 10vw; text-transform: uppercase;.section.marquees:height: 150vh; display: flex; flex-direction: column; justify-content: center; background-color: #fff;(the white plate the strips live on; taller than one viewport so the strips scroll through it)..marquee-container:position: relative; width: 125%; height: 250px; display: flex; gap: 1em; margin-bottom: 1em; overflow: hidden;. The 125% width intentionally overflows the viewport so the strip has slack to slide horizontally;overflow: hiddencrops it..marquee:width: 100%; height: 100%; position: absolute; top: 50%; left: 0; transform: translateY(-50%); display: flex; gap: 1em;.#marquee-1 .marquee, #marquee-3 .marquee { left: -15%; }— rows 1 and 3 start pre-shifted 15% to the left (only these two)..item:flex: 1; display: flex; justify-content: center; align-items: center;..item.with-text:flex: 1.5;(the word cell is wider than the image cells)..item h1:text-transform: uppercase; font-size: 140px;.- Include the standard Lenis helper CSS:
html.lenis, html.lenis body { height: auto; },.lenis.lenis-smooth { scroll-behavior: auto !important; },.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; },.lenis.lenis-stopped { overflow: hidden; },.lenis.lenis-smooth iframe { pointer-events: none; }. - Responsive
@media (max-width: 900px):section.marquees→height: 100vh;.marquee-container→width: 250%; height: 150px;#marquee-2 .marquee, #marquee-4 .marquee { left: -35%; };.item.with-text { flex: 1; };.item h1 { font-size: 60px; }.
GSAP effect (be exhaustive)
Wrap everything in a DOMContentLoaded listener. gsap.registerPlugin(ScrollTrigger).
1. Split the words into characters
const splitText = new SplitType(".item h1", { types: "chars" }); — SplitType wraps every character of every word <h1> in a .char span (inline-block), which is what lets each letter carry its own animated fontWeight. Run this once, before creating the tweens.
2. Per-character font-weight bloom (the star effect)
Define a helper:
function animateChars(chars, reverse = false) {
gsap.fromTo(
chars,
{ fontWeight: 100 },
{
fontWeight: 900,
duration: 1,
ease: "none",
stagger: {
each: 0.35,
from: reverse ? "start" : "end",
ease: "linear",
},
scrollTrigger: {
trigger: chars[0].closest(".marquee-container"),
start: "50% bottom",
end: "top top",
scrub: true,
},
}
);
}
Details that matter:
- Animated property:
fontWeight100 → 900on each.char. Because the font's weight axis is continuous, letters visibly thicken from hairline to black. ease: "none"on the tween andease: "linear"on the stagger — the weight ramp is perfectly linear; the scrub supplies the feel.stagger.each: 0.35withfromflipping per row creates a directional wave: whenreverseisfalsethe stagger startsfrom: "end"(last letter blooms first, wave travels right-to-left); whenreverseistrueit startsfrom: "start"(first letter first, left-to-right).- ScrollTrigger: triggered by the word's own
.marquee-container,start: "50% bottom"(begins when the row's mid-point hits the bottom of the viewport),end: "top top"(finishes when the row's top reaches the top),scrub: true— so the weight is a direct, reversible function of scroll.
3. Horizontal drift of each marquee strip + wiring the char bloom
Iterate every .marquee-container with its index:
document.querySelectorAll(".marquee-container").forEach((container, index) => {
let start = "0%";
let end = "-15%";
if (index % 2 === 0) { // rows 1 & 3 (0-based even)
start = "0%";
end = "10%";
}
const marquee = container.querySelector(".marquee");
const words = marquee.querySelectorAll(".item h1");
gsap.fromTo(
marquee,
{ x: start },
{
x: end,
scrollTrigger: {
trigger: container,
start: "top bottom",
end: "150% top",
scrub: true,
},
}
);
words.forEach((word) => {
const chars = Array.from(word.querySelectorAll(".char"));
if (chars.length) {
const reverse = index % 2 !== 0; // odd rows reverse the bloom
animateChars(chars, reverse);
}
});
});
Details that matter:
xis animated as a percentage string ("0%","10%","-15%") — GSAP interprets this as a percentage of the strip's own width, not pixels. Even-index rows (1 & 3) driftx: "0%" → "10%"(rightward); odd-index rows (2 & 4) driftx: "0%" → "-15%"(leftward). Adjacent rows therefore slide in opposite directions.- Combined with the CSS
left: -15%on rows 1 & 3, this opposing motion plus the 125%-wide overflowing strips gives the classic contra-scrolling marquee look. - ScrollTrigger for the drift:
trigger: container,start: "top bottom"(begins as the row enters from the bottom),end: "150% top"(ends well after it passes),scrub: true. Note this drift trigger has a wider scroll window than the char-bloom trigger (which runs"50% bottom" → "top top"), so the letters finish blooming before the strip finishes drifting. - The
reverseflag passed toanimateCharsis tied to row parity, so the bloom wave direction alternates row to row in sync with the drift direction.
4. Lenis smooth scroll wired into GSAP's ticker
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Lenis drives the scroll; ScrollTrigger reads it via the scroll event, and Lenis's raf is stepped from GSAP's ticker (with lagSmoothing(0)). This is what makes the scrubbed weight-blooms and drifts feel buttery.
No timeline, no CustomEase, no Three.js, no pinning. Every animation is an independent scrubbed fromTo — the two families of tweens (per-strip x drift and per-character fontWeight bloom) run in parallel, each governed by its own ScrollTrigger.
Assets / images
17 images total, all object-fit: cover, no logos or brand marks:
- 1 hero image — a full-bleed, surreal/dystopian, dreamlike 3D-rendered scene filling the opening 100vh viewport (portrait-friendly, but it's cropped to cover).
- 16 marquee thumbnails — surreal dystopian 3D-rendered stills in the same visual family, distributed 4 per row across the 4 strips. In their 250px-tall cells they read as roughly square-to-portrait crops. They interleave with the four word cells (
Unique,Release,2500,Rarity) exactly in the positions listed in the Layout section.
Behavior notes
- Whole page hijacks native scroll via Lenis; all animation is scroll-scrubbed and fully reversible (scrolling up un-blooms the weights and reverses the drift).
- The font-weight bloom depends entirely on a weight-variable font — with a static font nothing will interpolate, so verify the variable file loads.
- Alternating drift directions come purely from
index % 2(rows 1 & 3 vs rows 2 & 4); the bloom-wave direction alternates the same way via thereverseflag. - Responsive: on ≤900px the strips get wider (250%) and shorter (150px), the headline size drops to 60px, and rows 2 & 4 gain a
left: -15%-equivalent pre-shift (-35%); the animation logic is unchanged. - Desktop-first editorial showcase; no reduced-motion branch in the original.