EmpireUI
Get Pro
← Blog9 min read#scroll reveal#react#animation

Scroll Reveal Animation in React: AOS vs Framer Motion vs Intersection Observer

AOS, Framer Motion, or raw Intersection Observer — which actually belongs in your React project? A practical breakdown with real code and honest trade-offs.

Abstract colorful gradient representing scroll animation transitions in React

The Three Contenders — and Why This Choice Actually Matters

You've got a landing page. Elements should animate in as the user scrolls. Simple enough, right? Except every tutorial you find either reaches for AOS like it's 2017, throws a 40 KB Framer Motion dependency at a three-line problem, or assumes you're comfortable wiring up IntersectionObserver from scratch. None of them compare all three honestly.

Here's the real split: AOS (Animate On Scroll) is a plain JS library that works in React but doesn't think in React. Framer Motion is a React-first animation library that does scroll reveal as one of maybe 50 features. The native IntersectionObserver API is built into every modern browser since 2018 and has zero bundle cost. That last one is criminally underused.

Choosing the wrong tool doesn't just affect bundle size — it affects how you handle SSR, reduced-motion preferences, and whether your animations feel like they belong to the page or were bolted on. Worth thinking about before you npm install anything.

AOS: Fast to Ship, Awkward to Maintain

AOS works by reading data-aos attributes on DOM elements and toggling CSS classes when those elements enter the viewport. Initialize it once in a useEffect and you're done. That's genuinely appealing for a quick marketing page where you don't want to think about animation state.

In practice, though, AOS fights React constantly. It manipulates the DOM directly, which means it can desync from your component tree after re-renders. You have to call AOS.refresh() manually whenever content changes — inside a useEffect with the right deps array — and even then it's brittle if you're dynamically loading content.

import { useEffect } from 'react';
import AOS from 'aos';
import 'aos/dist/aos.css';

export function FadeInSection({ children }) {
  useEffect(() => {
    AOS.init({ duration: 700, once: true, offset: 80 });
  }, []);

  return <div data-aos="fade-up">{children}</div>;
}

Honestly, this works fine for static sites or simple landing pages. The once: true option is your best friend — nobody wants elements that animate every time they scroll back up. But if you're building a React app with dynamic data, route changes, or lazy-loaded components, AOS will start costing you debugging time around month two.

One more thing — AOS ships data-aos-* attributes as the API. That's a lot of non-semantic HTML. It also doesn't give you any React lifecycle hooks, so you can't conditionally trigger animations based on component state. It's a one-trick horse, and the trick has a ceiling.

Framer Motion: The Full Kitchen Sink

Framer Motion's whileInView prop landed in v5.3 and it's genuinely good. You declare what the element looks like when it's visible, what it looks like before that, and Framer handles the rest. It's React-native, SSR-safe, and respects prefers-reduced-motion automatically if you set it up right.

import { motion } from 'framer-motion';

export function RevealCard({ children }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 40 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: '-80px' }}
      transition={{ duration: 0.5, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}

The viewport prop's margin option is doing real work here — -80px means the element starts animating 80px before it hits the viewport edge, so users see the animation in progress rather than snapping in. That 80px offset is something you'll tune per design.

Where Framer earns its keep is orchestration. If you're already using it for page transitions, hover states, drag interactions, or layout animations, the marginal cost of adding scroll reveal is basically nothing. It plugs into the same mental model you're already using.

That said, if scroll reveal is the *only* animation on your page, Framer Motion is overkill. The minified+gzipped bundle is around 45 KB. For a component that just fades something in, you're burning a lot of bytes. Know what you're paying for.

Intersection Observer: The Underdog That Scales

The native IntersectionObserver API lets you subscribe to visibility changes on any DOM element. No library. No bundle cost. Full control. And since 2019, browser support has been effectively universal — you're not excluding anyone meaningful by using it.

The catch is you have to wire it up yourself. But that's maybe 25 lines of code, and once it's a custom hook, you're done forever. Here's the hook I'd actually use in production:

import { useEffect, useRef, useState } from 'react';

export function useInView(options?: IntersectionObserverInit) {
  const ref = useRef<HTMLDivElement>(null);
  const [isInView, setIsInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsInView(true);
        observer.unobserve(el); // fire once
      }
    }, { threshold: 0.1, rootMargin: '-60px 0px', ...options });

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return { ref, isInView };
}

Then your component is just CSS transitions driven by a class toggle. The animation itself lives in your stylesheet, which means designers can tweak it without touching component logic. That separation of concerns ages well.

export function FadeUp({ children }: { children: React.ReactNode }) {
  const { ref, isInView } = useInView();

  return (
    <div
      ref={ref}
      className={`transition-all duration-500 ease-out ${
        isInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
      }`}
    >
      {children}
    </div>
  );
}

Quick aside: the translate-y-10 in Tailwind maps to 40px — same as the 40px offset in the Framer example. Keeping these values consistent across your design system matters more than people admit.

Performance: What's Actually Happening Under the Hood

All three approaches ultimately animate CSS properties. The performance story depends on *which* properties you animate. Animating opacity and transform is free — the browser composites these on the GPU without triggering layout or paint. Animating height, margin, or top is expensive. This is true regardless of which library you pick.

Where they differ is in observer setup. AOS creates one IntersectionObserver for all watched elements — efficient. Framer Motion creates observers per-component but pools them internally since v6. Your own useInView hook creates one observer per component by default, which can get heavy if you have hundreds of elements on a long page. If that's your situation, write a shared observer context.

Look, the real performance win is { once: true } semantics — making animations fire exactly once. Users scrolling back up shouldn't trigger re-animations. All three options support this, but you have to explicitly opt in everywhere except when using useInView with observer.unobserve() baked in like the example above.

Worth noting: if you're building component-heavy UIs and want to see how scroll reveal interacts with complex component designs — glassmorphism components are a great stress test, since backdrop-filter + opacity animations can stress the compositing layer in interesting ways.

Accessibility: The Part Everyone Skips

Your scroll reveal animations should respect prefers-reduced-motion. Full stop. Some users have vestibular disorders where moving content causes physical discomfort. This isn't optional polish — it's basic accessibility.

With raw CSS + Intersection Observer, you handle it in your stylesheet. One media query covers everything:

@media (prefers-reduced-motion: reduce) {
  .fade-up {
    transition: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}

With Framer Motion, you can use the useReducedMotion hook and pass reduced variants conditionally. With AOS, you unfortunately have to wrap AOS.init in a motion check yourself — it doesn't handle this automatically. That's a meaningful gap for a library that's supposed to abstract away complexity.

// Framer approach
import { useReducedMotion } from 'framer-motion';

const shouldReduce = useReducedMotion();
const variants = {
  hidden: shouldReduce ? {} : { opacity: 0, y: 40 },
  visible: { opacity: 1, y: 0 },
};

Which One Should You Actually Use?

Here's the honest version: if you're already using Framer Motion in your project, use whileInView. The API is clean, it's React-idiomatic, and you're not adding any bundle weight. If you want a great starting point for animated UI patterns, browse the Empire UI component library — most components are already built with motion in mind.

If you're not using Framer Motion elsewhere, write the useInView hook. Twenty-five lines, zero dependencies, works forever. It also pairs naturally with whatever CSS animation approach you're already using — Tailwind transitions, CSS modules, whatever. You're also free to plug in the gradient generator or glassmorphism generator and know that your animations won't conflict with complex background effects.

AOS is fine for static sites, Jekyll templates, or WordPress pages where you need something in 10 minutes and don't care about React integration quality. In a real React codebase in 2026, you're better off with either of the other two options.

If stagger animations across lists or grids are on your roadmap, Framer Motion pulls ahead significantly — its staggerChildren API is genuinely hard to match with pure CSS. But for simple fade-up on scroll? The native IntersectionObserver + CSS transitions combo is smaller, faster to understand, and easier to debug at 2am when something breaks.

FAQ

Can I use AOS with Next.js without errors?

Yes, but wrap AOS.init() in a useEffect with an empty dependency array so it only runs client-side. AOS touches the DOM directly, so calling it during SSR will throw. Also call AOS.refresh() on route changes if you're using the App Router.

Does Framer Motion's whileInView work with Next.js SSR?

It does. Framer Motion v6+ is SSR-safe by default — elements render in their initial state on the server and animate client-side once hydrated. Just don't use the legacy AnimatePresence + layout tricks with whileInView simultaneously or you'll get hydration mismatches.

What's the threshold value in IntersectionObserver — what should I use?

A threshold of 0.1 means the animation triggers when 10% of the element is visible. That works well for most cases. For tall elements like full-screen sections, use 0 so the animation starts the moment any pixel enters the viewport.

Should scroll reveal animations fire more than once?

Almost always no. Use once: true in AOS, viewport={{ once: true }} in Framer, or observer.unobserve() in your custom hook. Re-animating on scroll-back feels cheap and can disorient users.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Reveal on Scroll in CSS: @keyframes + Intersection Observer HybridMotion for React (Framer Motion) in 2026: layout, AnimatePresence, GesturesFramer Motion vs GSAP in 2026: Honest Comparison for Real ProjectsMotion (Framer Motion) vs GSAP in 2026: Which Wins for React?