Tutorial GSAP ScrollTrigger 5 min read

Change a Background
Image on Scroll in HTML

Tie one image to another and let the page do the work: as the visitor scrolls down, the first photo dissolves into the second. Plain HTML, a little CSS, and a single GSAP ScrollTrigger. No frameworks, no build step.

A scroll-linked background swap is one of those effects that feels expensive and is almost free. Nothing here is a video, a sprite sheet, or a canvas. It's just two images stacked in the same spot, with the top one fading away as you scroll. Because the fade is wired to scroll position rather than a clock, the visitor is the one driving it — scroll down and the picture changes, scroll back up and it changes back.

This is exactly the move behind the service cards on the BriteWire home page. In this tutorial we'll rebuild the Online Marketing card from scratch: two photos, a readable text overlay, and one GSAP ScrollTrigger that cross-fades the cover image into the one beneath it as the card climbs the screen.

The finished effect

Here's what we're building — the real Online Marketing card. Two versions of the same shot are stacked on top of each other. As this card moves up your viewport, the top photo cross-fades out (and settles from a gentle 1.15× zoom) to reveal the photo underneath. Scroll the page up and down and watch the background image change with you.

Online Marketing

Change Background Image on scroll using:


  • → HTML
  • → CSS
  • → GSAP ScrollTrigger

Live demo: the cover photo cross-fades to the one below it as you scroll.

How it works

There's no magic background switch in CSS — you can't transition background-image from one URL to another. So we don't try. Instead we lay both images in the same place and animate the top one's opacity. Fade the top photo to transparent and the bottom photo simply shows through. The whole technique is three ideas:

  • Stack two images in one cell. A CSS grid where every child shares grid-column: 1; grid-row: 1; puts the base photo and the "start" photo perfectly on top of each other.
  • Fade the top one, don't swap it. The cover image animates from opacity: 1 to opacity: 0, revealing the base image. A simultaneous 1.15 → 1.0 zoom adds a subtle settle.
  • Let scroll drive the timeline. GSAP's ScrollTrigger ties the fade's progress to the card's position in the viewport, so the visitor scrubs the animation by scrolling.

A dark gradient scrim sits between the photos and the text so the white copy stays readable no matter which image is showing.

Step 1: Stack the two images

Start with the markup. Both <img> tags point at the two versions of the photo — online-marketing-hero.webp is the base, and ...-hero-start.webp is the cover we'll fade out. The scrim and the text overlay round out the card.

<div class="swap-card">
  <img class="swap-bg swap-bg-base"  src="online-marketing-hero.webp"       alt="Online Marketing">
  <img class="swap-bg swap-bg-start" src="online-marketing-hero-start.webp" alt="" aria-hidden="true">
  <div class="swap-scrim"></div>
  <div class="swap-content">
    <h3>Online Marketing</h3>
    <p>From visual systems to storytelling, we help brands communicate their true voice.</p>
    <ul>
      <li>→ Email marketing</li>
      <li>→ Social media strategy</li>
      <li>→ Campaigns &amp; promotions</li>
    </ul>
    <a href="/services/online-marketing.html">Explore Online Marketing →</a>
  </div>
</div>

The cover image is purely decorative — it's the same scene as the base — so it gets alt="" and aria-hidden="true" to keep screen readers from announcing it twice.

Step 2: Lay them on top of each other with CSS

The trick that makes everything else easy is a one-cell grid. Set display: grid on the card, then send every child to the same grid-column and grid-row. They all stack in document order, so the base photo sits at the bottom, the start photo over it, the scrim over that, and the text on top.

.swap-card {
  display: grid;
  position: relative;
  overflow: hidden;
}

/* Everything shares one grid cell, so it all stacks. */
.swap-card > * {
  grid-column: 1;
  grid-row: 1;
}

.swap-bg {
  width: 100%;
  height: auto;
  aspect-ratio: 687 / 1030; /* reserve the box before the image loads */
  object-fit: cover;
  will-change: transform;
}

/* Dark top-down gradient keeps the white text legible. */
.swap-scrim {
  background: linear-gradient(180deg,
    rgba(0,0,0,0.60) 0%,
    rgba(0,0,0,0.35) 35%,
    rgba(0,0,0,0.10) 70%,
    rgba(0,0,0,0.00) 100%);
  pointer-events: none;
}

.swap-content {
  position: relative;
  z-index: 1;
  padding: 2rem;
  display: flex;
  flex-direction: column;
  color: #fff;
  text-shadow: 0 1px 3px rgba(0,0,0,0.45);
}

Note the aspect-ratio on the images: it reserves the card's shape before the lazy-loaded photos arrive, so nothing jumps as the page settles. Because the children are grid-stacked rather than position: absolute, the tallest one (the image) defines the card's height automatically.

Step 3: Drive the swap with ScrollTrigger

Now the payoff. We register GSAP's ScrollTrigger plugin and build one tween that fades the cover image from opacity: 1 to 0. The scrollTrigger block ties that fade to the card's journey through the viewport: it starts when the card's top reaches 80% down the screen and ends as it passes the middle. scrub: 0.5 is the magic word — it links the animation's progress directly to scroll position (with a touch of smoothing), so scrolling up reverses it.

gsap.registerPlugin(ScrollTrigger);

// Fade the cover photo away as the card climbs the viewport.
gsap.fromTo('.swap-bg-start',
  { opacity: 1 },
  {
    opacity: 0,
    ease: 'none',
    scrollTrigger: {
      trigger: '.swap-card',
      start: 'top 80%',   // begin when the card enters
      end: 'center 40%',  // finished a little past center
      scrub: 0.5          // tie progress to scroll position
    }
  });

// At the same time, settle BOTH photos from a 1.15x zoom to rest.
gsap.fromTo('.swap-card .swap-bg',
  { scale: 1.15 },
  {
    scale: 1,
    ease: 'none',
    scrollTrigger: {
      trigger: '.swap-card',
      start: 'top 80%',
      end: 'center 40%',
      scrub: 0.5
    }
  });

That's the entire effect. As the visitor scrolls down, the cover image's opacity walks from 1 to 0 while the photo eases out of its zoom — the background appears to change. Scroll back up and ScrollTrigger runs the timeline in reverse, re-fading the cover image back in. The visitor is the playhead.

Step 4: Handle motion preferences

Two safeguards make this production-ready. First, GSAP may load deferred, so guard the script and only run it once the library is present. Second — and this matters — some visitors ask their system to reduce motion. For them we skip the animation entirely and just show the final image by hiding the cover photo outright.

document.addEventListener('DOMContentLoaded', function () {
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // No GSAP, or motion isn't welcome? Show the final image and stop.
  if (!window.gsap || !window.ScrollTrigger || reduce) {
    document.querySelectorAll('.swap-bg-start').forEach(function (img) {
      img.style.opacity = 0;
    });
    return;
  }

  gsap.registerPlugin(ScrollTrigger);
  // ...the two fromTo() tweens from Step 3 go here...
});

Because the layout is a fluid grid with an aspect-ratio and percentage-width images, responsiveness comes for free: the card scales to whatever column it lands in, on a phone or a wide desktop, and the scroll math is all relative to the viewport. The demo at the top of this page is running exactly this code right now.

Make it your own

  • Swap the photos. Any two images that share a frame work — day to night, before to after, closed to open. Keep them the same dimensions so nothing shifts.
  • Tune the timing. Widen the gap between start and end for a slower, more deliberate dissolve; tighten it for a snappy flip.
  • Drop the scrub. Prefer a one-shot fade instead of a scroll-scrubbed one? Replace scrub with toggleActions: 'play none none reverse' and a fixed duration.
  • Layer in more. The zoom is just a second tween on the same trigger. Add a rise, a tilt, or a content reveal the same way — everything stays in sync because it shares one ScrollTrigger.

The takeaway

That's the whole technique. Stack two images in one grid cell, put a scrim and your text on top, and fade the cover image's opacity with a single scrubbed ScrollTrigger. No video, no canvas, no swapping URLs — just one honest cross-fade the visitor controls by scrolling.

You don't change a background image on scroll. You stack two and let the visitor dissolve one into the other.

Scroll-driven moments like this are how we make brand stories feel alive at BriteWire. See the approach in our online marketing work, or read more from the BriteWire blog.

Start a project with us