D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Text Appears, Hides, Then Animates In Again? A `force-reveal` Safety Net That Rescued Too Much

· · 6 min read
Text Appears, Hides, Then Animates In Again? A `force-reveal` Safety Net That Rescued Too Much

The most annoying bugs usually do not come from careless code. They come from safety code. I hit a perfect example on a pre-launch landing page: the exact mechanism I added so animations could never fail turned out to be the thing the client reported as broken.

The report arrived in wonderfully unscientific language: "the text shows up, then it animates, then the text is back".

The symptom only appeared for people who read

What kept me from finding it quickly is that the bug never showed up in the way I test. I scroll fast, jump between sections, refresh, repeat. Everything looked fine.

The client does not browse like that. They read. They opened the page, sat on the hero for a moment, read the headline, then drifted downward slowly while taking the copy in. With that rhythm the bug was perfectly reproducible:

  1. The client scrolls down slowly.
  2. A block of text is already fully visible before it properly enters the viewport.
  3. The moment it does enter the viewport, the text disappears.
  4. Then the entrance animation runs from scratch and the text comes back.

So copy the client was halfway through reading got yanked away and reinstalled with a flourish. Technically nothing was failing. The console was clean. To someone actually reading the page, it felt like the site was glitching.

Why the safety net existed in the first place

The context matters, because that net was not idle code. The animation rules on that project were deliberately paranoid, born from an older wound on a different build: reveal elements must never be fully hidden, the observer runs with a low threshold (0.05), every animated element is tagged explicitly one by one, and a last-resort net force-reveals everything roughly four seconds after load.

One thing I have to own here. On that older project the net did check whether an element was actually on screen before force-revealing it. What travelled into this project was only the compressed version of the rule, "a roughly four second force-reveal net", and that check got left behind on the way. A rule squeezed into one line tends to lose the very part that made it safe.

The reasoning is sound. IntersectionObserver can fail to fire for plenty of reasons: a very fast scroll that skips past an element without a captured intersection frame, layout shifting once fonts and images land, browser quirks. If that happens and the element stays in its pre-state, the content is gone permanently with no trace.

So I shipped this:

window.addEventListener("load", () => {
  setTimeout(() => {
    document.querySelectorAll(".reveal:not(.is-visible)").forEach((el) => {
      el.classList.add("is-visible");
    });
  }, 4000);
});

Four seconds pass, anything still hidden gets revealed. Safe, I thought.

The root cause: the net rescued things that never asked for rescuing

Look at the selector. document.querySelectorAll(".reveal:not(.is-visible)") grabs every reveal element on the page. All of them. Including the ones in the footer, three screens below where the visitor is currently standing.

Four seconds after load, the net opened all of them. Below-fold elements, whose observers had correctly not fired yet because those elements were nowhere near the viewport, were un-hidden too. Visually nothing was wrong at that moment, because nobody was looking at that part of the page.

The damage only landed later. When the visitor finally scrolled down there, the observer did exactly its job: the element entered the viewport, the callback ran, the entrance animation fired. And that entrance is driven by anime.js, which applies its own pre-state at the moment the animation plays, not at page load.

Which produces this sequence:

Exactly what the client described: text, animation, text. The net and the observer were both working perfectly. What never happened was the two of them telling each other anything.

And why only on a leisurely scroll? Because a fast scroll gets past that content long before the four second timer matters, or the element enters the viewport before the net ever touches it. This bug needs someone who actually reads to surface it, which is the most important visitor you have.

The fix: make the net viewport aware, and give it strikes

I went back to the basic question: what is this net actually protecting against?

The answer is a single, very narrow case: an element that is on screen right now, but whose observer has not fired. That is it. A below-fold element sitting in its pre-state is not a failure, it is correct behaviour. Rescuing it is not just unnecessary, it is actively harmful.

So the net went from a one-shot sweep to a periodic patrol that only looks at what is genuinely on screen. And to avoid accusing an element whose observer was about to fire anyway, I gave it a strike system: an element has to be caught in the "on screen but still not revealed" state twice in a row, 1.5 seconds apart, before it gets force-revealed.

const STRIKE_INTERVAL = 1500;
const STRIKES_NEEDED = 2;
const strikes = new WeakMap();
 
function isOnScreen(el) {
  const rect = el.getBoundingClientRect();
  return rect.top < window.innerHeight && rect.bottom > 0;
}
 
function patrol() {
  const pending = document.querySelectorAll(".reveal:not(.is-visible)");
 
  pending.forEach((el) => {
    if (!isOnScreen(el)) {
      strikes.delete(el); // below the fold, none of the net's business
      return;
    }
 
    const count = (strikes.get(el) || 0) + 1;
    strikes.set(el, count);
 
    if (count >= STRIKES_NEEDED) settle(el);
  });
}
 
window.addEventListener("load", () => {
  setInterval(patrol, STRIKE_INTERVAL);
});

The second half of the fix matters just as much. The net must not only change how an element looks, it has to close the case so the observer cannot replay anything later. So both paths go through one door:

function settle(el) {
  if (el.classList.contains("is-visible")) return; // once per element, ever
 
  el.classList.add("is-visible");
  io.unobserve(el); // the observer will never fire for this element again
  playEntrance(el); // the anime.js call, from the pre-state
}
 
const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) settle(entry.target);
    });
  },
  { threshold: 0.05 }
);

playEntrance there is the anime.js call that used to sit directly inside the observer callback. It is now only reachable through settle, and settle bails on its first line if the element has already been revealed. The entrance animation still runs, but at most once in that element's lifetime.

Now whichever gets there first, the net or the observer, that element stops being an animation candidate. There is no second path left that can slap a pre-state on top of content the visitor is already reading.

After that change, a slow scroll through the live landing page is calm again. Text enters once, from its pre-state, the way it was meant to.

What I took away