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-fallback100msFades aa-trigger="load" / aa-trigger="load-once" 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. Closed modals are excluded (their natural state is hidden).

Drop this snippet into <head> above the alrdy-animate <script> and <link> tags — and ideally above any other render-blocking <script> (e.g. Typekit), so the two timers start counting from page load rather than after that script downloads:

<!-- alrdy-animate FOUC guard + slow-network/timeout safety. -->
<script>
const FALLBACK_DELAY = 100; // ms — load elements fade in via CSS keyframe
const TIMEOUT_DELAY = 4000; // ms — universal reveal if init never runs
const selectors = '[aa-trigger~="load"][aa-animate], [aa-trigger~="load-once"][aa-animate]';
const styled = new WeakSet();
const applyFallback = () => {
if (document.documentElement.hasAttribute('aa-loaded')) return;
document.documentElement.setAttribute('aa-fallback', '');
document.querySelectorAll(selectors).forEach((el) => {
if (styled.has(el)) return;
styled.add(el);
const baseDelay = parseFloat(el.getAttribute('aa-delay')) || 0;
if (baseDelay > 0) el.style.animationDelay = baseDelay + 's';
});
};
// Poll, not DOMContentLoaded — render-blocking scripts can delay it past the timers.
const deadline = performance.now() + FALLBACK_DELAY + 8000;
const tryFallback = () => {
if (document.documentElement.hasAttribute('aa-loaded')) return;
applyFallback();
if (document.readyState === 'loading' && performance.now() < deadline) {
setTimeout(tryFallback, 100);
}
};
setTimeout(tryFallback, FALLBACK_DELAY);
setTimeout(() => {
if (document.documentElement.hasAttribute('aa-loaded')) return;
document.documentElement.setAttribute('aa-timeout', '');
}, TIMEOUT_DELAY);
</script>
<style>
/* FOUC guard (excluding LCP) */
[aa-animate]:not([aa-ready]):not([aa-trigger~="lcp"]) { visibility: hidden; }
[aa-animate][aa-trigger~="lcp"]:not([aa-ready]) { visibility: visible; opacity: 0.01; }
/* Fallback handling */
@keyframes aa-fallback-appear {
from { opacity: 0; }
to { opacity: 1; }
}
html[aa-fallback] [aa-trigger~="load"][aa-animate]:not([aa-ready]),
html[aa-fallback] [aa-trigger~="load-once"][aa-animate]:not([aa-ready]) {
visibility: visible;
animation: aa-fallback-appear 0.5s ease both;
}
html[aa-fallback] [aa-animate][aa-trigger~="lcp"]:not([aa-ready]) { opacity: 1; transition: opacity 0.5s ease; }
/* Timeout handling */
html[aa-timeout] [aa-animate]:not([aa-ready]) { visibility: visible; }
html[aa-timeout] [aa-modal-group] [aa-animate]:not([aa-ready]) { visibility: hidden; } /* Keep modals hidden */
html[aa-timeout] [aa-animate][aa-trigger~="lcp"]:not([aa-ready]) { opacity: 1; }
</style>
<noscript><style>[aa-animate]{visibility:visible!important;opacity:1!important;transform:none!important;filter:none!important;clip-path:none!important}</style></noscript>

Tune the two constants up top. At FALLBACK_DELAY = 100ms the CSS fade usually drives the hero entrance (a cold bundle rarely beats 100ms) — smooth and bundle-independent; raise it toward 1000ms to give GSAP first shot at the entrance instead. Keep TIMEOUT_DELAY well above it — that's the "give up and show the whole page" net. The script reads each element's aa-delay and sets animation-delay inline so the staircase still reads; it ignores aa-stagger (the split units don't exist pre-bundle, so it fades each element as a whole).

The FOUC guard excludes lcp (it paints on purpose at the 0.01 floor), so the lcp floor + reveal rules live in this same <head> block.

Why inline — the FOUC guard + lcp floor also ship in dist/alrdy-animate.css, but that file loads after first paint (non-blocking), so without an inline guard every animated element flashes visible, then hides when the guard lands, then reveals at init(). The fallback keyframe + aa-fallback / aa-timeout rules ship only here (nothing else sets those attributes), so the snippet is their single source — no cross-origin dist copy to collide with.

Independent of the fallback, but the same kind of first-paint win: preload the font your hero text uses so it's available on the first frame. GSAP text effects (line / char splits) measure the rendered font, and the hero is usually your LCP element — a late font swap reflows the split and pushes out LCP. Add this near the top of <head> with your real font URL (Webflow: DevTools → Network → Font, or the site's CSS — cdn.prod.website-files.com/<site-id>/<asset>.woff2):

<link rel="preload" as="font" type="font/woff2" crossorigin
href="https://cdn.prod.website-files.com/YOUR_SITE_ID/YOUR_FONT.woff2">

init() sets aa-loaded on <html> as its first step; both timers bail if it's already set.

  • aa-fallback (1s) — fades load / load-once elements up via the keyframe (respecting aa-delay). Scoped to those triggers, so below-the-fold scroll elements stay hidden until the lib arrives. When the bundle lands, the load branches detect aa-fallback and skip their JS animation (no replay/flash); init() clears the attribute.
  • aa-timeout (4s) — the deep-fail net: reveals every [aa-animate] at its natural state, no animation. Closed modals stay hidden (a modal card's aa-animate is its open entrance — the html[aa-timeout] [aa-modal-group] rule re-hides them). When init() arrives under aa-timeout it skips appearance features (no rewind from-states) but still wires interactive components + globals, then clears the attribute. Tradeoff: entrance animations don't play on that load — the user already saw the content for 4s+.

The default keyframe is a plain opacity fade. To use a different fallback motion (fade-up, slide-down, scale-in), edit @keyframes aa-fallback-appear directly in the snippet — it's the only definition, so there's nothing to override:

@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 edit the keyframe.

Why it polls (don't gate on DOMContentLoaded)

Section titled “Why it polls (don't gate on DOMContentLoaded)”

A render-blocking <script> (Typekit, or Webflow's jQuery/GSAP bundle) keeps document.readyState at 'loading' and delays DOMContentLoaded — on a slow connection, past TIMEOUT_DELAY. So a DOMContentLoaded gate would never run the staircase. The snippet instead polls for the hero (which parses early, near the top of <body>) and applies the moment it appears.

Still add defer to the CDN <script> tags you control (they download in parallel, init() runs sooner — you can't defer Webflow's injected scripts), and load alrdy-animate's GSAP build instead of Webflow's native GSAP, not both.

The fallback only matters when something can leave the FOUC guard's visibility: hidden in place — i.e. whenever init() can be slow or fail to run. You can skip the snippet if:

  • You don't use the FOUC guard at all — you don't load alrdy-animate/style (or otherwise apply the [aa-animate] { visibility: hidden } rule), and you accept a brief reflow as init() applies from-states. With nothing hiding the elements, there's nothing to reveal.
  • The bundle is inlined into the initial HTML payload (not a separate <script src> / chunk) and you're comfortable with elements staying invisible in the rare case init() throws.

"It's a Vite / Next.js / Webpack app, not a CDN script" is not, on its own, a reason to skip it — see Using it in a bundled app below. A separately-loaded bundle is still subject to download latency and chunk-load failures, both of which leave guarded elements hidden.

If you only want one of the two layers, delete the other layer's setTimeout(...) (for aa-fallback, the tryFallback block and its scheduling setTimeout) and its corresponding CSS rule.

The moment you load alrdy-animate/style in a Next.js / Vite / Webpack app, the same two risks apply: the FOUC guard hides every [aa-animate] element until init() runs, and init() waits on the JS bundle — which a slow connection delays and a failed chunk-load (or an error inside init() itself) can prevent outright, leaving the SSR'd hero invisible while the rest of the page renders. So you want the same two layers.

One rule that's easy to get wrong: the timer must run independently of your app bundle. Put it in a useEffect or a top-level module import and it can't fire until the bundle loads — which is the very thing it's meant to beat. It has to be inline in the document <head>, ahead of (or beforeInteractive relative to) your app's JS.

Use the same <script> + <style> + <noscript> as the snippet at the top of this page — only the placement and one trim change:

  • The base FOUC guard and lcp opacity floor already come from import 'alrdy-animate/style' (a render-blocking <link>, present at first paint), so you don't re-inline those two rules — only the fallback keyframe and the html[aa-fallback] / html[aa-timeout] reveal rules, which no longer ship in dist.
  • The html[aa-fallback] lcp rule out-specificities the imported 0.01 floor regardless of <link> order, so it still wins.
import 'alrdy-animate/style' // FOUC guard + lcp floor (render-blocking, at first paint)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* Fallback keyframe + reveal rules — NOT in dist, so inline them here. */}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes aa-fallback-appear { from { opacity: 0 } to { opacity: 1 } }
html[aa-fallback] [aa-trigger~="load"][aa-animate]:not([aa-ready]),
html[aa-fallback] [aa-trigger~="load-once"][aa-animate]:not([aa-ready]) { visibility: visible; animation: aa-fallback-appear .5s ease both }
html[aa-timeout] [aa-animate]:not([aa-ready]) { visibility: visible }
html[aa-timeout] [aa-modal-group] [aa-animate]:not([aa-ready]) { visibility: hidden } /* keep closed modals hidden */
html[aa-fallback] [aa-animate][aa-trigger~="lcp"]:not([aa-ready]) { opacity: 1; transition: opacity .5s ease }
html[aa-timeout] [aa-animate][aa-trigger~="lcp"]:not([aa-ready]) { opacity: 1 }
` }} />
{/* The timer — paste the <script> body from the snippet at the top of this page. */}
<script dangerouslySetInnerHTML={{ __html: `/* FALLBACK_DELAY / TIMEOUT_DELAY timer from the snippet above */` }} />
{/* JS-disabled reveal — same <noscript> as the snippet above. */}
<noscript><style dangerouslySetInnerHTML={{ __html: `[aa-animate]{visibility:visible!important;opacity:1!important;transform:none!important;filter:none!important;clip-path:none!important}` }} /></noscript>
</head>
<body>{children}</body>
</html>
)
}

A raw <script dangerouslySetInnerHTML> in the <head> is injected into the SSR'd HTML and runs before hydration, so it beats the bundle without needing next/script — but <Script strategy="beforeInteractive"> from next/script works too. The timer only ever sets aa-* attributes on <html> and an inline animation-delay on plain DOM nodes — neither is a React-controlled prop, so there's no hydration mismatch.

Next.js Pages Router — pages/_document.tsx

Section titled “Next.js Pages Router — pages/_document.tsx”

Put the same three tags inside <Head> in _document.tsx (using dangerouslySetInnerHTML for the <script> and <style>). Keep the import 'alrdy-animate/style' in _app.tsx.

Paste the snippet verbatim into your index.html <head>, exactly like the Webflow block — there's no async-import subtlety, the inline <style> is the single source.

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.

  • aa-fallback — DevTools → Network → "Disable cache" + throttle to Slow 3G, reload. The hero should fade in (<html aa-fallback> in the Elements panel) before the 4s timeout, and the bundle landing later should not replay it. If you only ever get the abrupt 4s reveal, you have an old copy that gated on DOMContentLoaded — re-copy the current (polling) snippet.
  • aa-timeout — Network → block the alrdy-animate <script> URL, reload. Everything stays hidden ~4s, then reveals at its natural state (<html aa-timeout>).