Sticky Title Scroll Effect

Multiple headings are stacked in a sticky viewport container and revealed one after another as the user scrolls. Each heading's characters animate in from invisible, then fade out in reverse before the next heading appears.

GSAPScrollTriggerSplitTextStickyScroll

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
ScrollTrigger CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>

Code

index.html
html
<section data-sticky-title="wrap" class="sticky-title-wrapper">
  <div class="sticky-title-container">
    <div class="sticky-title-inner">
      <h2 data-sticky-title="heading" class="sticky-title-el">Use this effect to really emphasize your message</h2>
      <h2 data-sticky-title="heading" class="sticky-title-el is--stacked">You can layer multiple headings on each other</h2>
      <h2 data-sticky-title="heading" class="sticky-title-el is--stacked">Add as many as you want, but I like the balance of 3</h2>
    </div>
  </div>
</section>
styles.css
css
.sticky-title-wrapper {
  background-image: linear-gradient(#000, #777);
  width: 100%;
  height: 350vh;
  position: relative;
}

.sticky-title-container {
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  padding-left: 1.5em;
  padding-right: 1.5em;
  display: flex;
  position: sticky;
  top: 0;
}

.sticky-title-inner {
  text-align: center;
  flex-flow: column;
  justify-content: center;
  align-items: center;
  width: 100%;
  max-width: 60em;
  margin-left: auto;
  margin-right: auto;
  display: flex;
  position: relative;
}

.sticky-title-el {
  margin-top: 0;
  margin-bottom: 0;
  font-size: 5em;
  font-weight: 500;
  line-height: 1;
}

.sticky-title-el.is--stacked {
  visibility: hidden;
  position: absolute;
}

@media screen and (max-width: 767px) {
  .sticky-title-el {
    font-size: 3.5em;
  }
}
script.js
javascript
gsap.registerPlugin(ScrollTrigger, SplitText)

function initStickyTitleScroll() {
  const wraps = document.querySelectorAll('[data-sticky-title="wrap"]');

  wraps.forEach(wrap => {
    const headings = Array.from(wrap.querySelectorAll('[data-sticky-title="heading"]'));

    const masterTl = gsap.timeline({
      scrollTrigger: {
        trigger: wrap,
        start: "top 40%",
        end: "bottom bottom",
        scrub: true,
      }
    });

    const revealDuration  = 0.7;
    const fadeOutDuration = 0.7;
    const overlapOffset   = 0.15;

    headings.forEach((heading, index) => {
      heading.setAttribute("aria-label", heading.textContent);

      const split = new SplitText(heading, { type: "words,chars" });
      split.words.forEach(word => word.setAttribute("aria-hidden", "true"));

      gsap.set(heading, { visibility: "visible" });

      const headingTl = gsap.timeline();
      headingTl.from(split.chars, {
        autoAlpha: 0,
        stagger: { amount: revealDuration, from: "start" },
        duration: revealDuration,
      });

      if (index < headings.length - 1) {
        headingTl.to(split.chars, {
          autoAlpha: 0,
          stagger: { amount: fadeOutDuration, from: "end" },
          duration: fadeOutDuration,
        });
      }

      masterTl.add(headingTl, index === 0 ? undefined : `-=${overlapOffset}`);
    });
  });
}

document.addEventListener("DOMContentLoaded", () => {
  initStickyTitleScroll();
});

Notes

  • The wrapper height controls how much scroll distance is available for all headings — increase it when adding more headings (e.g. 4 headings → `450vh`).
  • No parent element of `.sticky-title-container` should have `overflow: hidden` or `overflow: clip`, as this breaks CSS sticky positioning.
  • Stacked headings use `position: absolute` so they occupy the same space; `visibility: hidden` is reset to `visible` by `gsap.set` just before animation.
  • Accessibility is handled manually here: the full text is stored in `aria-label` on the heading, and individual split words are marked `aria-hidden` so screen readers read the original sentence only once.
  • The `overlapOffset` (0.15) starts the next heading's fade-in slightly before the current one finishes fading out, creating a more dynamic crossfade feel.