06

Reveal on view

The IntersectionObserver ONLY writes data-attributes — data-js on mount, data-inview on first intersect, then it disconnects. Every visual is CSS gated on [data-js], so SSR, no-JS and reduced-motion render the finished layout. Scroll down.

<RevealOnView>[data-js][data-inview]reveal-stagger

The observer can only DELAY a reveal — never lose content. If JS never runs, nothing was ever hidden. If the user prefers reduced motion, the attributes are set immediately and no keyframe fires.

Solo reveal

the whole panel rises once, on the 500ms spring

This panel was in the server HTML the whole time — the wrapper only delayed its entrance until you could see it. View source: no opacity: 0 anywhere until hydration set data-js.

requests / s
18,204 4.2%
p99 latency
212ms 8ms
error budget
94.1% 0
open incidents
2 1

The contract

attributes in, CSS out

useEffect(() => {
  el.setAttribute("data-js", "");                  // JS is alive — MAY hide now
  if (reducedMotion) { el.setAttribute("data-inview", ""); return; }
  const io = new IntersectionObserver(([e]) => {
    if (e.isIntersecting) { el.setAttribute("data-inview", ""); io.disconnect(); }
  }, { rootMargin });
  io.observe(el);
}, []);

/* CSS — the only animator (motion.css §5) */
[data-reveal][data-js]:not([data-inview]) { opacity: 0; }     /* hide AFTER hydration */
[data-reveal][data-inview] { animation: panel-in …; }          /* reveal on sight     */