All components

ScrollTrigger Variable Font Marquee

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Four horizontal image marquees drift sideways as you scroll (gsap.fromTo on x driven by a scrubbed ScrollTrigger), while SplitType splits the interspersed word headings into characters whose variable fontWeight is tweened from 100 to 900 with a staggered, scrubbed ScrollTrigger. Lenis provides the smooth scroll that ScrollTrigger reads.

How it's built

Categorytext
Techgsap, split-type, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scrolltrigger variable-font font-weight marquee split-text lenis editorial scrub gsap

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-1marquee-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-face with a .ttf/.woff2 weight-variable file. Apply it to html, body as the global font-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;. Its h1: 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: hidden crops 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.marqueesheight: 100vh; .marquee-containerwidth: 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: fontWeight 100 → 900 on each .char. Because the font's weight axis is continuous, letters visibly thicken from hairline to black.
  • ease: "none" on the tween and ease: "linear" on the stagger — the weight ramp is perfectly linear; the scrub supplies the feel.
  • stagger.each: 0.35 with from flipping per row creates a directional wave: when reverse is false the stagger starts from: "end" (last letter blooms first, wave travels right-to-left); when reverse is true it starts from: "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:

  • x is 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) drift x: "0%" → "10%" (rightward); odd-index rows (2 & 4) drift x: "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 reverse flag passed to animateChars is 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 the reverse flag.
  • 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.