All writing

Never let an animation decide whether your site is visible

Almost every fade-in-on-scroll effect works by hiding your content first and revealing it with JavaScript. Which means the failure mode is not "no animation". It is "no website".

The pattern is everywhere, and it looks harmless:

.reveal      { opacity: 0; transform: translateY(22px); }
.reveal.is-in{ opacity: 1; transform: none; }

Then an IntersectionObserver adds .is-in as each element scrolls into view. Elegant, cheap, compositor-friendly. And it contains a quiet assumption: that the JavaScript will run.

If it does not — a syntax error in an unrelated bundle, a blocked script, an over-eager corporate proxy, a browser extension, a CDN having a bad afternoon — then every element with that class stays at opacity: 0. Your page loads. Your server returns 200. Your HTML is perfect. The visitor sees white.

We watched it happen

Building this site, the preview environment turned out not to be painting frames. A direct test confirmed the observer never fired a single callback — not late, not partially: zero. Result: 0 of 34 elements revealed, and a stat band frozen reading "0".

That particular environment was an artefact of our own tooling, not something a real visitor would hit. But it demonstrated the failure exactly, and the lesson stands on its own: if an animation hook is the only thing standing between your content and the viewer, you have made visibility conditional on code succeeding.

Two guards, about six lines

First: scope the hidden state to JavaScript being alive. An inline script in the <head>, before first paint, marks the document:

<script>document.documentElement.classList.add('js');</script>

Then the hidden state only applies when that class is present:

.js .reveal      { opacity: 0; ... }
.js .reveal.is-in{ opacity: 1; ... }

No JavaScript, no .js class, no hidden state — the page renders plainly and completely. It must be inline and in the head; a deferred external file is exactly the thing that might not arrive.

Second: give the observer a deadline. Scoping to .js handles JavaScript being absent. It does not handle JavaScript running while the observer never reports — throttled tabs, background renderers, and the situation above. So set a watchdog:

var reported = false;
var io = new IntersectionObserver(function (entries) {
  reported = true;
  /* ...reveal as normal... */
});

setTimeout(function () {
  if (!reported) revealAll();
}, 1200);

If the observer has not spoken once inside 1.2 seconds, stop waiting and show everything. A visitor who misses a fade-in loses nothing. A visitor who gets a blank page is gone.

The general shape

Progressive enhancement gets discussed as though it were about supporting ancient browsers. It is not, really. It is about which direction your code fails in.

  • Content visible by default; the effect is what gets added.
  • Never make the base state depend on a script completing.
  • Any async mechanism that gates visibility needs a timeout.
  • Ask of each animation: if this never runs, what does the visitor see?

Same reasoning applies to counters that start at zero in the markup, tabs whose panels are hidden until initialised, and accordions that collapse before hydration. If the enhancement dies, the content should still be there.

All writing Start a project