D
P
0

Web Animation

Scroll-Reveal Content Stays Invisible When IntersectionObserver Never Fires

June 13, 2026·3 min read
Scroll-Reveal Content Stays Invisible When IntersectionObserver Never Fires

This one briefly made me panic, because the symptom wasn't "the animation is broken" — it was the content is gone. On a cinematic site I built, a hero image and a few product cards would sometimes simply not appear. Not faded, not half-revealed: completely blank, as if the elements weren't there at all. Refreshing fixed it sometimes, and sometimes it didn't. That intermittency is what made it so maddening.

When I finally tracked it down, the root cause was in the scroll-reveal pattern I'd used — and one small, fatal decision: the reveal elements started fully hidden.

A fully-hidden initial state is a trap

The pattern went like this. Every .reveal element was fully clipped via CSS, and an IntersectionObserver added the .is-visible class once the element entered the viewport.

.reveal {
  clip-path: inset(0 0 100% 0); /* fully clipped — invisible */
}
.reveal.is-visible {
  clip-path: inset(0 0 0 0);
}
const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) entry.target.classList.add("is-visible");
    });
  },
  { threshold: 0.15, rootMargin: "-10%" }
);
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));

The problem: with threshold: 0.15 + rootMargin: "-10%", there are plenty of edge cases where the observer never fires. A fast scroll can blow past an element without a captured intersection frame. An element sitting in the viewport but intersecting below the 0.15 threshold won't count as intersecting. Browser quirks and layout timing add more uncertainty.

And here's the fatal part: when the observer fails, .is-visible never gets added, so clip-path: inset(0 0 100% 0) persists forever. The content is permanently gone — no console error, no clue whatsoever.

The fix: never fully hide — degrade to visible

The principle I hold now: a reveal's initial state must never be fully-hidden. If the trigger fails, the content must still be visible. I built three layers of defense.

Layer 1 — a self-recovering initial state

Instead of a full clip, I use a combination of properties that each have an effect, plus a clip that's only a sliver (8%, not 100%). If any single property fails to reset, the others still reveal the content.

.reveal {
  opacity: 0;
  transform: translateY(32px);
  clip-path: inset(0 0 8% 0); /* a sliver, NOT 100% */
  transition: opacity 0.8s ease, transform 0.8s ease, clip-path 0.8s ease;
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
  clip-path: inset(0 0 0 0);
}

Layer 2 — a more forgiving observer

I lowered the threshold from 0.15 to 0.05 and reset rootMargin to "0px", so even a small sliver of intersection is enough to fire.

const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) entry.target.classList.add("is-visible");
    });
  },
  { threshold: 0.05, rootMargin: "0px" }
);

Layer 3 — a JavaScript safety net

About 4 seconds after load, I force-add .is-visible to any .reveal element that's already in the viewport but still missing the class.

window.addEventListener("load", () => {
  setTimeout(() => {
    document.querySelectorAll(".reveal:not(.is-visible)").forEach((el) => {
      const rect = el.getBoundingClientRect();
      const inView = rect.top < window.innerHeight && rect.bottom > 0;
      if (inView) el.classList.add("is-visible");
    });
  }, 4000);
});

And of course, honor prefers-reduced-motion: for users who ask for it, show everything immediately with no animation.

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    clip-path: none;
    transition: none;
  }
}

Closing notes

  • A reveal's initial state must never be fully-hidden. If the trigger fails, fully-hidden content disappears forever. Always degrade to visible.
  • Use multiple properties, not one. opacity + transform + a sliver clip cover for each other's failures.
  • Clip with a sliver (8%), not 100%. A 100% clip is the same as hiding it; a sliver still leaves the content visible.
  • Always ship a safety net. The observer is not a guarantee; a fallback timer that checks the viewport closes the edge cases.
  • Honor prefers-reduced-motion. Accessibility and bug-resilience often come from the same decision.

The lesson in one sentence: design reveal animations so that a failed trigger means the content still shows up — not that it vanishes.