Skip to content

Slow-network + init-timeout fallback

The lib hides every [aa-animate] element with visibility: hidden until init() runs. On a slow connection (or if the bundle is blocked, errors, or is misconfigured), that leaves animated content invisible. This snippet adds two layers of safety:

LayerDefaultWhat it does
aa-fallback1sFades aa-trigger="load" elements (hero copy) via a CSS keyframe — graceful staircase, respecting aa-delay and aa-stagger.
aa-timeout4sReveals every [aa-animate] element at its natural state with no animation — universal safety net if init never runs.

Drop this snippet into <head> above the alrdy-animate <script> and <link> tags:

<script>
// Tune these two numbers without reading the rest of the snippet.
const FALLBACK_DELAY = 1000; // ms — hero copy fades in via CSS keyframe
const TIMEOUT_DELAY = 4000; // ms — universal reveal if init never runs
const applyFallback = () => {
if (document.documentElement.hasAttribute('aa-loaded')) return;
document.documentElement.setAttribute('aa-fallback', '');
document.querySelectorAll('[aa-trigger~="load"][aa-animate]').forEach((el) => {
const stagger = parseFloat(el.getAttribute('aa-stagger'));
const baseDelay = parseFloat(el.getAttribute('aa-delay')) || 0;
if (Number.isFinite(stagger) && el.children.length > 0) {
// Parent is a container (e.g. card grid) — show it without fading,
// animate each direct child with the same stagger the JS init applies.
el.style.animation = 'none';
el.style.visibility = 'visible';
Array.from(el.children).forEach((child, i) => {
child.style.animation = 'aa-fallback-appear 0.5s ease both';
child.style.animationDelay = (baseDelay + i * stagger) + 's';
});
} else if (baseDelay > 0) {
el.style.animationDelay = baseDelay + 's';
}
});
};
setTimeout(() => {
// If the body hasn't been parsed yet (slow connection blocking on
// render-blocking <script> tags), defer to DOMContentLoaded so the
// elements actually exist when we querySelectorAll them.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyFallback, { once: true });
} else {
applyFallback();
}
}, FALLBACK_DELAY);
setTimeout(() => {
if (document.documentElement.hasAttribute('aa-loaded')) return;
document.documentElement.setAttribute('aa-timeout', '');
}, TIMEOUT_DELAY);
</script>
<style>
[aa-animate]:not([aa-ready]) { visibility: hidden; }
@keyframes aa-fallback-appear {
from { opacity: 0; transform: translateY(1.5rem); }
to { opacity: 1; transform: translateY(0); }
}
html[aa-fallback] [aa-trigger~="load"][aa-animate]:not([aa-ready]) {
visibility: visible;
/* `both` so the from-state holds during any pre-start delay too. With
`forwards`, a delayed element renders at its natural opacity during
the delay, then jumps to opacity 0 at start — visible flicker. */
animation: aa-fallback-appear 0.5s ease both;
}
html[aa-timeout] [aa-animate]:not([aa-ready]) { visibility: visible; }
</style>

Tune the two constants at the top to taste. Keep TIMEOUT_DELAY well above FALLBACK_DELAY — the first is “let JS win the race for the hero”, the second is “give up, show the page”.

Why the inline <style> — the same FOUC + fallback rules ship in dist/alrdy-animate.css, but on a slow connection that file lands after the HTML renders. Without inlining, every animated element flashes briefly visible, then disappears when the lib’s CSS arrives, then fades back in when the timeout fires. Inlining the critical CSS in <head> closes that window — the FOUC guard is active from the very first paint.

The script mirrors the two timing attributes the JS init already understands, so the fallback reveal looks like a quieter version of the real animation:

  • aa-delay="<seconds>" — applies a delay to the element’s own fade-up. E.g. a paragraph with aa-delay="0.2" appears 200ms after the headline.
  • aa-stagger="<seconds>" on a parent (with children) — the parent itself shows immediately (no fade), and each direct child fades up at i * stagger, just like the JS path animates the children with stagger when they’re inside an aa-stagger parent. If the parent also has aa-delay, that’s added as a base offset to every child.

CSS alone can’t read aa-delay or expand aa-stagger across non-sibling children — :nth-child only works among siblings, CSS counters can’t drive animation-delay, and typed attr() isn’t widely supported yet. The 18-line script handles both rules in one pass.

init() sets aa-loaded on <html> as its first synchronous step. The two timers check for that attribute and bail out if it’s already set.

Sets html[aa-fallback]. The CSS rule (matching [aa-trigger~="load"], so it covers both aa-trigger="load" and combined forms like aa-trigger="load event:enter") fades those elements up via a keyframe animation, respecting aa-delay and aa-stagger so the staircase still plays.

When the bundle eventually arrives, the lib’s load-trigger branches detect aa-fallback and skip their JS animation entirely — the elements stay where the fallback put them, no replay or flash. init() clears aa-fallback at the end, so subsequent Barba navigations don’t re-trigger the keyframe.

Scoped to [aa-trigger~="load"] on purpose: scroll-triggered elements below the fold stay hidden until the lib actually arrives. Only elements the author has explicitly marked as “needs to be visible immediately” are eligible for the fallback fade.

Sets html[aa-timeout]. The CSS rule reveals every [aa-animate] element at its natural rendered state — no animation, no from-state. This is the deep-fail safety net: if JS errored, the bundle is blocked, or the network is so slow that even the hero staircase isn’t enough, the page still ends up usable.

When init() eventually arrives, it detects aa-timeout and switches to bypass mode:

  • Drops scroll, text, reveal, parallax from the feature load set — no from-states applied, no entrance ScrollTriggers, no flash from rewinding the now-visible elements.
  • Skips the reduced-motion fade pass.
  • Still wires up interactive features (tabs, slider, marquee, modal, nav, hover, cursor, split) so components remain functional.
  • Still mounts smooth-scroll, scroll-state, and scroll-target globals.
  • Flips aa-ready on every [aa-animate] element so the DOM ends in a consistent state.
  • Clears aa-timeout, so the next Barba navigation runs animations normally.

The tradeoff: appearance animations don’t play on this load (the user already saw the content for 4+ seconds), and event-triggered scroll/text/reveal entries don’t fire on this page. At that point, page consistency outweighs animation correctness.

The default keyframe is a small upward translate (translateY(1.5rem) → 0) plus opacity. To use a different fallback motion (slide-down, pure fade, scale-in), override the keyframe in your own CSS after the inline snippet:

@keyframes aa-fallback-appear {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}

The duration, easing, and selector stay the same — just the keyframe content changes.

For the per-element delays to actually fire on slow networks, the body needs to be parsed by the time the timer fires. Add defer to every <script src="…"> in <head>:

<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alrdy-animate@8.0.0-alpha.0/dist/alrdy-animate.umd.cjs"></script>

Without defer, every <script> blocks HTML parsing — on slow 4G the body hasn’t been parsed by the time the 1s timer fires, so querySelectorAll returns no elements and the per-element delays never get applied. The snippet’s built-in DOMContentLoaded guard catches this case as a safety net (it defers the apply step until the DOM is ready), but by then the lib bundle has usually finished loading too and init() runs first — meaning the fallback never fires at all on slow networks.

defer flips this: the parser sails through the body without waiting for downloads. The [aa-trigger~="load"] elements exist by the time the timer fires, the snippet applies their delays inline, and the CSS keyframe plays out the staircase before the lib bundle even finishes downloading.

You can skip the snippet if any of these are true:

  • The bundle ships in your own JS bundle (Vite / Next.js / Webpack), not from a CDN. In that case bundle availability is bound to page render, not network latency, and a JS error would prevent the React tree itself from mounting — there’s no aa-animate flash to protect against.
  • You’re shipping behind aggressive caching where most visitors hit a warm bundle and you’re comfortable with elements staying invisible on the rare error case.

If you only want one of the two layers, delete the other setTimeout block and its corresponding CSS rule.

This snippet only protects the first page load. On subsequent navigations the bundle is cached and init() runs in beforeEnter parallel to the leave timeline (see the Webflow + Barba recipe). aa-loaded persists across navigations, so the fallback timer’s check passes immediately and nothing fires.

  1. Confirm your CDN <script> tags use defer (see above) — without it the body isn’t parsed when the timer fires and the per-element delays can’t apply.
  2. Open DevTools → Network → check “Disable cache” → throttle to “Slow 3G” (or “Slow 4G”).
  3. Reload. You should see:
    • Hero elements stay invisible for ~1s (the FALLBACK_DELAY interval).
    • At the timer mark, they fade up — respecting any aa-delay and aa-stagger you set, so the staircase plays out before the bundle finishes downloading.
    • When the bundle eventually lands, no second animation plays — the elements stay where the fallback put them.
  4. Open the Elements panel and inspect a load-trigger element while the fallback is in flight. You should see style="animation-delay: 0.2s" (or style="animation: aa-fallback-appear 0.5s ease 0.1s 1 normal both running" on a stagger child). If those inline styles are missing, the body wasn’t parsed when the timer fired — check your defer attributes.

Layer 2 — aa-timeout (bundle never arrives)

Section titled “Layer 2 — aa-timeout (bundle never arrives)”
  1. In DevTools → Network, block the alrdy-animate <script> URL (right-click the request → Block request URL).
  2. Reload. You should see:
    • All [aa-animate] elements stay hidden for ~4s (the TIMEOUT_DELAY interval).
    • At the timer mark, every animated element on the page becomes visible at its natural rendered state — no fades, no entrance motion.
    • The Elements panel shows <html aa-timeout>.
  3. Unblock the request and reload. Hero fades in via Layer 1 if it gets stuck under 4s, otherwise normal init runs and the snippet does nothing.