I was building the motion layer in a WordPress custom theme for a bilingual client site, French and English. One effect, a per-word reveal I called read-wipe, ran on the lead paragraphs (.section-lead, .story-text p, .visit-lead): each word starts faded and rises to full opacity as it enters the viewport. In most blocks it ran smoothly. But in a handful of leads the text got stuck. Not gone, not blank, just pinned at opacity: 0.25 forever. Permanently faded, even though the animation ran and the console was clean, no errors at all.
What made it maddening: nothing looked wrong. GSAP ran, IntersectionObserver fired, the tween completed. If I inspected the element, its inline style really was opacity: 0.25. As if the animation finished tweening to 1, but the element on screen never rose with it.
The symptom
I started with the cheap checks. I scrolled back and forth to confirm the observer fired. It fired. I logged the tween callback, onComplete ran. I checked the target selector, it resolved to the right nodes when the animation was built. Every indicator said success, yet the result stayed faded.
First dead end: I assumed a CSS specificity issue, some rule overriding opacity back to 0.25. I combed the stylesheet, nothing. Second dead end: I thought another timeline was killing the tween mid-flight. I isolated it, still faded. Third dead end: I briefly blamed SplitText and rewrote its split config, no change.
What finally cracked it: I compared the node I was tweening against the node actually in the DOM right now. The animation's target node was no longer attached to the document. The node on screen was a different one.
Why it happens
The clue I had skipped: every problem lead carried a data-i18n attribute. The site is bilingual, and a custom client-side i18n engine drives the translations. Its mechanism is simple: it caches each data-i18n element's innerHTML, then re-sets that innerHTML when it applies a language.
Here is the race. My reveal splitter ran first. It split each lead into per-word span elements and set inline opacity: 0.25 on every one. Only after that did the i18n engine run. When i18n cached the innerHTML, what it captured was already the split version, complete with the opacity: 0.25 baked in. Then i18n wrote that innerHTML back onto the element. The moment innerHTML is reassigned, the browser re-parses the string into brand new nodes. The old nodes, the ones GSAP was targeting, were detached from the DOM.
So the animation genuinely ran, but it was tweening ghost nodes that had already left the document. The live nodes on screen were the re-parsed ones, and they were born with the cached opacity: 0.25, with nothing tweening them to 1. Hence permanently faded.
There was a bonus layer. SplitText itself likes to re-split once fonts finish loading, to re-measure line positions. That re-split also rewrites the DOM and detaches nodes in exactly the same pattern. So there were two sources of detach: i18n and the font-load re-split.
The through-line: any element with data-i18n and a reveal that rewrites the DOM will fight, unless the reveal runs after i18n has finished.
The fix
Two parts.
First, ordering. i18n has to apply first, reveal second. I let i18n run on DOMContentLoaded, and only called buildSplitReveals after window.load plus document.fonts.ready. That way, by the time the splitter carves up the text, i18n will not rewrite innerHTML again, and fonts have settled so SplitText will not re-split.
document.addEventListener('DOMContentLoaded', applyI18n);
window.addEventListener('load', () => {
document.fonts.ready.then(() => {
buildSplitReveals();
});
});Second, I dropped SplitText for the leads and switched to a stable hand-rolled splitter using a .rwi class. It splits once and never re-splits itself. The reveal is a plain IntersectionObserver that, when the element enters the viewport, re-queries the live .rwi nodes at that exact moment and tweens those:
function splitLead(el) {
const words = el.textContent.trim().split(/\s+/);
el.innerHTML = words.map((w) => `<span class="rwi">${w}</span>`).join(' ');
}
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
gsap.to(entry.target.querySelectorAll('.rwi'), { opacity: 1, stagger: 0.02 });
io.unobserve(entry.target);
});
});The key detail is entry.target.querySelectorAll('.rwi'): the nodes are re-queried right as the animation is about to run, not saved from long before. So even if something rewrote the DOM in the meantime, the tween always lands on nodes that are truly alive on screen.
For the hero title, which also has a count-up, I gate it until the preloader is done. #loader gets an .is-done class at window.load plus 1400ms, which I watch with a MutationObserver so the reveal and count-up only start once fonts are loaded and the page is visible:
const loader = document.querySelector('#loader');
const mo = new MutationObserver(() => {
if (loader.classList.contains('is-done')) {
startHeroReveal();
mo.disconnect();
}
});
mo.observe(loader, { attributes: true, attributeFilter: ['class'] });As a safety net, I also wrote a pure-CSS reveal for a .word class, so if SplitText ever errors in some browser, the text still shows up instead of getting stuck faded.
Takeaways
- If reveal text stays faded even though the tween completed, suspect a detached target node, not a wrong opacity value.
- A
data-i18nelement and a DOM-rewriting reveal are two writers fighting over the same innerHTML. Run the reveal after i18n applies. - SplitText can re-split on font load and detach nodes. Wait for
document.fonts.readybefore splitting, or use a splitter that runs once. - Re-query the live nodes right before you tween, do not hold a node reference from earlier. If anything rewrites the DOM in between, the old reference is a ghost.
- Gate font- or layout-dependent animations until
window.loadand the preloader finish, so you never animate nodes that have not settled yet.
