A robust fade-in on scroll needs about twenty lines: a root class that opts into the hidden state, one IntersectionObserver that adds a revealed class and unobserves, and a CSS transition on opacity and transform only.
Problem / Scenario Context
A marketing team asks for "the sections to fade in as you scroll." The first implementation, copied from a snippet, puts opacity: 0 on every section in the main stylesheet and uses a scroll listener that calls getBoundingClientRect() on each section to decide when to add opacity: 1. It works on a laptop. On a budget phone, scrolling stutters, and when the analytics bundle fails to load on a flaky network, the page renders as a blank white column below the hero.
Both problems — the jank and the blank page — are design flaws, not tuning issues. The Scroll-Triggered Reveal Animations topic describes the failure modes in general; this page is the minimal implementation that avoids them.
Mechanics Explanation
The scroll-listener version does layout work on the main thread for every scroll event: each getBoundingClientRect() after a style change forces layout, and scroll events arrive at up to the display's refresh rate. With twenty sections that is twenty forced reads per event.
IntersectionObserver moves the geometry check into the browser's own rendering steps, where layout is already computed, and only calls your code when a section crosses the threshold. For a one-shot fade, each section produces exactly one callback in its lifetime — after which unobserve() removes it from the per-frame computation entirely.
The blank page comes from where the hidden state is declared. If the hiding rule lives in the base stylesheet, the page depends on a script to become readable. Gating the rule behind a class that the script itself adds inverts the dependency: without the script, the rule never matches.
Comparison Table: Where the Hidden State Lives
| Hidden state declared in… | No JS / script error | Slow script | Crawlers and previews |
|---|---|---|---|
Base stylesheet (.section { opacity: 0 }) |
page blank | page blank until load | may render blank |
| Inline style set by server | page blank | blank until load | may render blank |
Gated by .js-reveal added by the observer script |
fully visible | visible, then fades | visible |
<noscript> override |
visible | blank until load | depends |
Minimal Reproducible Example
/* The fragile version */
section { opacity: 0; transition: opacity .4s; }
section.show { opacity: 1; }
addEventListener('scroll', () => {
document.querySelectorAll('section').forEach((s) => {
if (s.getBoundingClientRect().top < innerHeight * 0.9) s.classList.add('show');
});
});
Block the script in DevTools (Network → right-click → Block request URL) and reload: everything below the fold is invisible forever.
Production-Safe Solution
// fade-in.ts
export function fadeInOnScroll(selector = '.fade-in'): () => void {
const targets = document.querySelectorAll<HTMLElement>(selector);
if (!targets.length || !('IntersectionObserver' in window)) return () => {};
// Opt in to the hidden state only now that we know the observer exists.
document.documentElement.classList.add('js-fade');
const io = new IntersectionObserver(
(entries, obs) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
(e.target as HTMLElement).classList.add('is-in');
obs.unobserve(e.target); // one callback per element, ever
}
},
{ rootMargin: '0px 0px 10% 0px', threshold: 0 }, // start slightly before entry
);
targets.forEach((t) => io.observe(t));
return () => io.disconnect();
}
.js-fade .fade-in:not(.is-in) {
opacity: 0;
transform: translateY(12px);
}
.fade-in {
transition: opacity 450ms ease-out, transform 450ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.js-fade .fade-in:not(.is-in) { transform: none; }
.fade-in { transition-duration: 150ms; }
}
<!-- Load it deferred; the page is readable before and without it. -->
<script type="module">
import { fadeInOnScroll } from '/js/fade-in.js';
fadeInOnScroll();
</script>
The 10% bottom margin starts the fade just before each section enters, which absorbs the delivery delay on a busy main thread. threshold: 0 guarantees tall sections reveal as soon as any part enters. And because only opacity and transform change, the browser can run the transition on the compositor without laying out or repainting neighbours.
Handling Content Added Later
Pages rarely stay static. A "load more" button, a client-side route change or a CMS embed can add new .fade-in elements after the initial call. There are two clean options.
Return an observe function from the module and call it for new elements. This is explicit and works well when the code that inserts content is yours:
export function createFader(margin = '0px 0px 10% 0px') {
document.documentElement.classList.add('js-fade');
const io = new IntersectionObserver((entries, obs) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add('is-in'); obs.unobserve(e.target); }
}, { rootMargin: margin });
return {
observe: (root: ParentNode = document) =>
root.querySelectorAll('.fade-in:not(.is-in)').forEach((el) => io.observe(el)),
disconnect: () => io.disconnect(),
};
}
Or let a MutationObserver register them when you do not control the insertion — third-party widgets, CMS blocks. Watch childList with subtree on the content container and call observe(record.target) per batch; the added and removed nodes guide covers reading the records efficiently.
Either way, the :not(.is-in) filter makes re-registration idempotent: already-revealed elements are never observed again.
Choosing Duration, Distance and Easing
The mechanics are only half of a good fade-in; the other half is keeping it quick enough that nobody waits for content. A few numbers hold up well across sites.
Duration of 300–500 ms. Shorter than about 250 ms and the effect reads as a flicker; longer than about 600 ms and fast readers catch up with the animation and read semi-transparent text.
Distance of 8–20 px. The translation is a cue that something arrived, not a journey. Larger distances make text move under the reader's eye and are the first thing reduced-motion users complain about.
Ease-out curves. Content should arrive quickly and settle gently. ease-out or a custom cubic-bezier(0.2, 0.6, 0.2, 1) feels responsive; ease-in feels sluggish because the start of the motion — the part the user notices — is slow.
No delay for single elements. A transition delay only makes sense for staggering several elements that arrive together, which is covered in staggered reveal animations.
Verification Steps
- Block the script and reload; every section must be fully visible.
- Throttle the CPU 6× and fling-scroll; no section should be visible at zero opacity for more than a frame.
- Record a Performance trace; there should be no scroll handler and no Layout triggered by the reveals.
- Turn on reduced motion and confirm sections fade without moving.
- Inspect a revealed section and confirm it is no longer observed (a breakpoint in the callback should not hit again when scrolling past it).
Common Mistakes to Avoid
- Putting the hidden state in the base stylesheet. It makes readability depend on a script.
- Using a high threshold for tall sections. A section taller than the viewport may never reach
threshold: 0.5. - Keeping elements observed after they reveal. Every later crossing queues a callback that does nothing.
- Animating
heightormargin. It shifts the content below and counts as layout shift.
FAQ
Why add the opt-in class from JavaScript instead of using a noscript tag?
A noscript override only helps when scripting is disabled entirely. It does nothing when the script is enabled but fails to download, throws, or is blocked by an extension — the more common cases. An opt-in class added by the working script covers all of them.
Does the fade-in delay Largest Contentful Paint?
Only if the LCP element starts hidden. Exclude the hero and anything above the fold from the fade-in selector, or ship them with the revealed class already applied.
Should I use will-change: transform on every fading element?
No. It promotes each element to its own layer for the lifetime of the page, which costs memory. Browsers promote elements automatically for the duration of a transform transition.
What if IntersectionObserver is not supported?
The function returns early without adding the opt-in class, so the content is simply visible with no animation. Every browser in current use supports the API, so this path mainly protects very old embedded webviews.
Related
- Staggered Reveal Animations for Card Grids — adding order to simultaneous reveals
- Respecting prefers-reduced-motion in Scroll Reveals — the motion-safe path in depth
- Building a Lazy Image Loader with IntersectionObserver — the same one-shot pattern for images
↑ Back to Scroll-Triggered Reveal Animations