You spent three hours adding scroll animations to your landing page. You followed a tutorial, used a plugin, tested it in Chrome — looked great. Then your client opened it on their phone with a mid-range Android, and the whole thing stuttered like a broken GIF. The headings lagged, the text jumped, and the smooth fade effect you were proud of looked like a PowerPoint from 2007.
I’m Rohan Ratnayake and I’ve spent the last 5 years as a frontend performance engineer for mid-sized SaaS companies and agency clients, and I’ve watched developers reach for jQuery plugins and bloated scroll libraries every single time they want a simple text fade. I’ve been in enough code reviews where a 14KB animation library was imported just to make one <h2> fade in. The page weight goes up, the Lighthouse score drops, and the “fancy” effect is barely noticeable anyway. I learned this the hard way on a client project back in 2021 — we had installed AOS.js (Animate on Scroll), a popular library, to handle fade-ins across a marketing site. The client’s Core Web Vitals score tanked, their LCP went from 1.8s to 3.4s, and we spent two days ripping it back out and rebuilding with a native solution. Total time wasted: roughly 11 hours across the team.
If you’ve been using a scroll animation plugin for something this simple, that’s not a skill gap — it’s a system problem. Nobody taught you that the browser already ships with the exact tool you need. This guide gives you a precise, copy-ready implementation using the Intersection Observer API and a handful of CSS lines. No dependencies. No frameworks. No excuses.

Why jQuery and CSS Libraries Get This Wrong
Let me be blunt about what most tutorials tell you to do: install AOS.js, add data-aos="fade-up" to your elements, and call it a day. That advice is lazy, and here’s what it actually costs you.
| Approach | JS Bundle Size | Extra HTTP Request | Works Without JS | Jank on Low-End Devices |
|---|---|---|---|---|
| AOS.js | ~13KB (minified) | Yes | No | Frequent |
| ScrollMagic | ~26KB | Yes | No | Yes |
| GSAP ScrollTrigger | ~60KB+ | Yes | No | Depends |
| Intersection Observer (native) | 0KB | No | Partially | Rare |
| Pure CSS only | 0KB | No | Yes | Almost never |
Every kilobyte your page loads competes with your actual content. For a text fade-in — one of the simplest visual effects on the web — loading a 13KB library is like hiring a chef to make toast.
The Intersection Observer API is built into every modern browser. According to MDN Web Docs, global browser support sits above 97%. You don’t need a polyfill for anything launched in the last four years.
What Intersection Observer Actually Does (In Plain Terms)

Think of it as a security camera that watches your elements. You tell it: “Hey, when this paragraph enters the viewport, let me know.” The browser fires a callback the moment that element crosses your defined threshold — no scroll event listeners, no requestAnimationFrame loops, no constant polling.
This matters because scroll event listeners fire dozens of times per second. Intersection Observer fires once, when the condition is met. Your CPU thanks you.
Here’s the mental model:
- Root: The viewport (or any scrollable container)
- Target: The element you want to watch (a heading, a paragraph, a section)
- Threshold: How much of the element needs to be visible before the callback fires (0.1 = 10% visible)
- rootMargin: A buffer zone — think of it as padding around the viewport
The CSS Setup: Keep It Simple
Before touching JavaScript, write your base styles. The element should start invisible and transition to fully visible. That’s two states. That’s all you need.
.fade-in-element {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in-element.is-visible {
opacity: 1;
transform: translateY(0);
}
The translateY(20px) gives a subtle upward movement as the element fades in. It makes the effect feel intentional without being dramatic. You can drop the transform lines entirely if you want a pure opacity fade — nothing wrong with that.
What each property does:
opacity: 0— starts invisibletransform: translateY(20px)— starts 20px below its natural positiontransition— tells the browser how to animate between the two states.is-visible— the class JavaScript will add at the right moment
Do not set visibility: hidden instead of opacity: 0. Hidden elements are completely removed from the accessibility tree, and screen readers will skip them until .is-visible is applied. Opacity-based hiding keeps the element in the DOM and accessible.
The JavaScript: 20 Lines That Handle Everything
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // stop watching after it's triggered
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
const targets = document.querySelectorAll('.fade-in-element');
targets.forEach((target) => observer.observe(target));
That’s it. Copy that. It works.
Breaking down the options:
| Option | Value Used | What It Means |
|---|---|---|
threshold | 0.1 | Fire when 10% of element is visible |
rootMargin | 0px 0px -50px 0px | Shrink the bottom of the viewport by 50px, so animation triggers before the element fully enters |
observer.unobserve() | Called after trigger | Stops watching the element — no re-animation on scroll up, no wasted observer cycles |
The observer.unobserve(entry.target) line is the one most tutorials skip. Without it, the observer keeps watching every element on every scroll event. On pages with 40+ animated elements, that adds up. Unobserve once triggered.
Adding Multiple Elements Without Repeating Code
Here’s where people get lazy and write five separate observers for five different element types. Don’t. One observer handles everything.
Add the class fade-in-element to any HTML element you want animated:
<h2 class="fade-in-element">Why Our Process Works</h2>
<p class="fade-in-element">We've refined this over 200 client projects...</p>
<div class="card fade-in-element">...</div>
The querySelectorAll('.fade-in-element') at the bottom of the script picks up every element with that class and registers it with the same observer instance. One observer, any number of targets.
Staggered Delays for Grouped Elements
If you have a row of three cards that should fade in one after another, use CSS custom properties to set individual delays without any extra JavaScript.
<div class="card fade-in-element" style="--delay: 0s">Card 1</div>
<div class="card fade-in-element" style="--delay: 0.15s">Card 2</div>
<div class="card fade-in-element" style="--delay: 0.3s">Card 3</div>
.fade-in-element {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out var(--delay, 0s),
transform 0.6s ease-out var(--delay, 0s);
}
The var(--delay, 0s) means: use the custom property if it’s set, otherwise default to 0s. Elements without a --delay inline style behave exactly as before.
Respecting Users Who Prefer Reduced Motion
This is the step most developers skip, and it’s arguably the most important one. Some users have vestibular disorders, epilepsy, or motion sensitivity. The prefers-reduced-motion media query exists precisely for this.
According to the Web Content Accessibility Guidelines (WCAG), motion that can’t be disabled creates a real barrier for certain users.
Add this to your CSS:
@media (prefers-reduced-motion: reduce) {
.fade-in-element {
opacity: 1;
transform: none;
transition: none;
}
}
This removes the animation entirely for users who’ve set that preference in their OS. The content is still visible — it just appears instantly. Two minutes of work. No reason not to do it.
Common Mistakes and How to Spot Them

| Mistake | What Happens | Fix |
|---|---|---|
Forgetting observer.unobserve() | Elements re-animate or observer runs unnecessarily | Add unobserve inside the if block |
Setting threshold: 1 | Animation only fires when element is 100% visible — rarely fires on mobile | Use 0.1 or 0.15 |
Using visibility: hidden instead of opacity: 0 | Screen readers skip the content | Always use opacity for fade-ins |
| Animating too many elements at once | Visual chaos; user can’t focus | Limit to key content blocks, not every <p> tag |
No rootMargin adjustment | Animation fires too late, especially on fast scrollers | Use negative bottom rootMargin to trigger slightly early |
When You Should NOT Use Scroll Animations
Not every page needs this. Here’s a quick test: if your content is primarily a how-to guide or a technical reference page, scroll animations add friction. Users want to read, not watch a performance.
Use fade-in on scroll when:
- You’re building a landing page or portfolio
- You want to direct visual attention to specific sections
- The page has distinct content blocks separated by white space
Skip it when:
- The page is long-form editorial content (like this article)
- Users are likely to Ctrl+F to search for specific info
- You’re building a dashboard or app interface
FAQs
Does Intersection Observer work on Safari? Yes. Safari added full support in version 12.1, released in 2019. For anything made in the last four or five years, you’re covered without a polyfill.
What’s the difference between threshold: 0 and threshold: 0.1? threshold: 0 fires the callback the moment even one pixel of the element enters the viewport. That’s often too early — the user may not have noticed the element yet. 0.1 waits until 10% is visible, which feels more natural and matches where the user’s eye lands.
Will this hurt my Core Web Vitals score? No, and it may actually help. Because Intersection Observer doesn’t run on the main thread and you’re using CSS transitions instead of JS-driven animations, there’s no layout thrashing. The CSS opacity and transform properties animate on the compositor thread, which is separate from layout and paint operations.
Wrapping Up
The scroll fade-in effect doesn’t need a library. It needs 20 lines of JavaScript and four lines of CSS. What kills page performance isn’t ambition — it’s reaching for a 30KB tool when the browser already ships the exact function you need.
Take the code in this article, drop the class on your elements, test it at 0.5x CPU throttle in Chrome DevTools, and check that your prefers-reduced-motion override works. That final check takes 90 seconds and is the difference between a professional implementation and a careless one.
If you want to go deeper on performance budgeting for animations, the Google Web Fundamentals guide on rendering performance is the most grounded resource I’ve found — not theoretical, practical.

