D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

"Text First, Then the Animation, Then the Text Again": `gsap.set` Waiting for the Preloader

· · 4 min read
"Text First, Then the Animation, Then the Text Again": `gsap.set` Waiting for the Preloader

Sometimes a bug report from the client is more precise than whatever technical phrasing I would have reached for instead. This time the sentence was "text first, then the animation, then the text again", and I am quoting it as it came in.

Six words in the original, and the whole sequence I needed to fix is already inside them. This was not something I spotted on my own screen. It came in from the client's side as a complaint about the hero intro animation sitting behind the preloader.

Unpacked, that sentence describes three consecutive stages. The hero text appears in full for one frame. Then that text gets hidden. Then it finally animates in the way it was supposed to.

One frame sounds too short to complain about. At 60 fps a frame is 1000 divided by 60, roughly 16 milliseconds. But a blink that short is still visible precisely because the contrast is extreme: full content, then empty, then filled again. Nobody has to read the text to notice that something showed up and got yanked away.

set() waits for the preloader, the hero does not

The shape of it was roughly this, with names and values simplified, and all three stages are visible in it. Every gsap.set initial state was called inside the preloader-finished callback, bundled together with the animation timeline.

document.addEventListener("DOMContentLoaded", () => {
  runPreloader({
    onComplete: () => {
      // the initial states are riding along in here, and that is the problem
      gsap.set(".hero__title span", { yPercent: 110, opacity: 0 });
      gsap.set(".hero__subtitle", { opacity: 0 });
 
      gsap
        .timeline()
        .to(".hero__title span", { yPercent: 0, opacity: 1, stagger: 0.06 })
        .to(".hero__subtitle", { opacity: 1 }, "-=0.3");
    },
  });
});

The catch is that the hero had already been rendered behind the preloader since page load. A preloader covers the hero, it does not postpone it. So for as long as the preloader sat in front, the hero was already there in its untouched state: full text, no yPercent, no opacity: 0, because not a single set() had run yet.

The moment the preloader lifted, the first frame reaching the visitor was that untouched full content. Only after that frame did set() run and hide it, and only then did the timeline animate it in. Exactly the order that was reported.

What makes this one pleasant to explain in hindsight: all of the code did run, and the full sequence reached the visitor. Only one thing was off, one piece of it ran a frame too late.

The fix: separate what prepares from what plays

The fix is not shifting timings or adding a delay. It is splitting the intro into two functions called at two different moments.

The first function holds ALL of the gsap.set initial states and is called immediately at DOMContentLoaded, while the preloader still covers the page. The second holds only the .to() timeline and is called when the preloader finishes.

function prepIntro() {
  // every initial state, no exceptions
  gsap.set(".hero__title span", { yPercent: 110, opacity: 0 });
  gsap.set(".hero__subtitle", { opacity: 0 });
}
 
function initIntro() {
  // only the .to() timeline, no set() in here
  gsap
    .timeline()
    .to(".hero__title span", { yPercent: 0, opacity: 1, stagger: 0.06 })
    .to(".hero__subtitle", { opacity: 1 }, "-=0.3");
}
 
document.addEventListener("DOMContentLoaded", () => {
  prepIntro();
  runPreloader({ onComplete: initIntro });
});

Now the hiding happens behind the preloader, at a moment when no eye can catch it. The first frame after the preloader lifts shows a hero already sitting in the animation's starting state instead of full content. The blink is gone not because it got faster, but because it moved behind the curtain.

One safety net for when the preloader timeline dies

There is a consequence to this split. The hero is now hidden earlier and more reliably, so if initIntro() never gets called, the page just sits there empty. That is why a failsafe timeout still calls the intro if the preloader timeline dies.

let introStarted = false;
 
function playIntroOnce() {
  if (introStarted) return;
  introStarted = true;
  initIntro();
}
 
// example number, tune it to a sensible preloader duration for your own site
const FAILSAFE_MS = 4000;
 
document.addEventListener("DOMContentLoaded", () => {
  prepIntro();
  runPreloader({ onComplete: playIntroOnce });
  setTimeout(playIntroOnce, FAILSAFE_MS);
});

The introStarted guard is there so the intro does not run twice when the preloader does finish normally before the timeout elapses.

Takeaways

The stack was GSAP and JavaScript inside a WordPress theme.