EmpireUI
Get Pro
← Blog8 min read#scroll reveal#css#intersection observer

Reveal on Scroll in CSS: @keyframes + Intersection Observer Hybrid

Skip the bloated animation library. Here's how to wire @keyframes and Intersection Observer together for buttery scroll reveals — zero runtime cost.

Code editor showing CSS animation keyframes with scroll reveal effects

Why Not Just Use a Library?

Short answer: you don't need one. AOS, ScrollReveal, and GSAP ScrollTrigger are great, but they all add JavaScript weight, override your existing CSS specificity in unexpected ways, and sometimes fight with React's rendering cycle. Honestly, for 80% of landing pages and portfolio sites, a 30-line vanilla solution works better.

The idea here is simple. You write your reveal animations as plain @keyframes in CSS — the browser's compositor handles them on the GPU. Then Intersection Observer (shipped in all major browsers since 2018, fully stable in Chrome 58+) watches your elements and flips a class when they enter the viewport. That's it. No polyfills needed unless you're targeting IE11, in which case, good luck.

Worth noting: this pattern plays especially well with React because you're not fighting against React's DOM model. You're just toggling a class via classList.add. The component doesn't re-render, no state updates happen, and your animations don't flicker when the component tree re-hydrates.

You can even combine this with the glassmorphism components or any other UI system — the reveal logic is completely decoupled from your component styles.

The CSS Side: Writing Your @keyframes

Let's start with the animation definitions. You want three states: the hidden state (what the element looks like before it's visible), the visible state (the final resting position), and the transition between them. Keep transforms cheap — opacity, transform: translateY(), and transform: scale() are compositor-friendly and won't cause layout thrash.

/* Base hidden state — applied before the element enters viewport */
.reveal {
  opacity: 0;
  transform: translateY(32px);
  transition: none; /* we'll use animation, not transition */
}

/* Triggered by Intersection Observer */
.reveal.is-visible {
  animation: fadeUp 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

@keyframes fadeUp {
  from {
    opacity: 0;
    transform: translateY(32px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

That cubic-bezier(0.22, 1, 0.36, 1) is a custom easing that starts fast and decelerates hard — it feels physical without being dramatic. Play with it in the gradient generator context panel if you want to visualize easing curves. The forwards fill mode is critical here: without it, the element snaps back to opacity: 0 the moment the animation completes.

You can add variations with data attributes instead of extra classes, which keeps your HTML clean. Something like data-reveal-delay='200' that you read in JS and apply as an inline animation-delay style. 40ms to 100ms stagger between sibling elements feels natural — anything over 200ms per item in a list starts feeling sluggish.

/* Variant animations — fade from left, scale in */
@keyframes fadeLeft {
  from { opacity: 0; transform: translateX(-24px); }
  to   { opacity: 1; transform: translateX(0); }
}

@keyframes scaleIn {
  from { opacity: 0; transform: scale(0.92); }
  to   { opacity: 1; transform: scale(1); }
}

.reveal[data-reveal='left'].is-visible {
  animation-name: fadeLeft;
  animation-duration: 0.5s;
  animation-fill-mode: forwards;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

.reveal[data-reveal='scale'].is-visible {
  animation-name: scaleIn;
  animation-duration: 0.45s;
  animation-fill-mode: forwards;
  animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}

The JS Side: Wiring Up Intersection Observer

Intersection Observer gets initialized once and observes everything with .reveal on it. You don't need a scroll event listener, you don't need requestAnimationFrame, and you don't need to throttle anything. The browser calls your callback when an element crosses the threshold you set.

// reveal.js — vanilla, no framework needed
const THRESHOLD = 0.15; // 15% of element visible before triggering

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      const el = entry.target;
      const delay = el.dataset.revealDelay ?? 0;

      // Apply stagger delay from data attribute
      el.style.animationDelay = `${delay}ms`;
      el.classList.add('is-visible');

      // Unobserve so animation only fires once
      observer.unobserve(el);
    });
  },
  { threshold: THRESHOLD, rootMargin: '0px 0px -48px 0px' }
);

document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));

The rootMargin: '0px 0px -48px 0px' is the secret sauce most tutorials skip. It shrinks the effective viewport by 48px at the bottom, so elements don't fire right at the very edge of the screen — they trigger 48px before they'd fully enter. Adjust that number based on your design. Tighter margins (like -80px) create a more dramatic late-reveal feel.

One more thing — call observer.unobserve(el) after the class is added. Otherwise you're observing dozens of DOM nodes forever, the observer callback fires on every tiny scroll tick, and you'll see it in your perf profiler. Unobserve. Always.

In practice, you'll also want a small guard for users with prefers-reduced-motion. The ethical thing to do is skip the animation entirely for those users. Two lines:

Respecting prefers-reduced-motion

Never ship scroll animations without this check. Some users get motion sick. Some are on older hardware where composited animations still stutter. prefers-reduced-motion: reduce is a real accessibility preference and ignoring it is a genuine UX failure.

// Before setting up the observer
const prefersReduced = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (prefersReduced) {
  // Just make everything visible immediately, skip the observer entirely
  document.querySelectorAll('.reveal').forEach((el) => {
    el.classList.add('is-visible');
    el.style.animation = 'none';
    el.style.opacity = '1';
    el.style.transform = 'none';
  });
} else {
  // Set up observer as normal
  document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
}

You can also handle this purely in CSS with @media (prefers-reduced-motion: reduce), but the JS approach above is cleaner when you also need to skip the observer itself rather than just override animation duration. Either works — pick whichever fits your architecture.

Look, this is three extra lines of code. There's no excuse not to include it.

React Hook: useReveal

If you're in a React app, wrap the whole thing in a custom hook so you're not scattering querySelectorAll calls everywhere and manually running effects on mount. The hook attaches to a ref, observes it, and returns a boolean you can use to apply the visible class (or inline styles if that's your preference).

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

interface UseRevealOptions {
  threshold?: number;
  rootMargin?: string;
  once?: boolean;
}

export function useReveal<T extends HTMLElement = HTMLDivElement>({
  threshold = 0.15,
  rootMargin = '0px 0px -48px 0px',
  once = true,
}: UseRevealOptions = {}) {
  const ref = useRef<T>(null);
  const [isVisible, setIsVisible] = useState(false);

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

    const prefersReduced = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    ).matches;

    if (prefersReduced) {
      setIsVisible(true);
      return;
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          if (once) observer.unobserve(el);
        } else if (!once) {
          setIsVisible(false);
        }
      },
      { threshold, rootMargin }
    );

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

  return { ref, isVisible };
}

Using it in a component is then dead simple:

export function FeatureCard({ title, body }: { title: string; body: string }) {
  const { ref, isVisible } = useReveal<HTMLDivElement>();

  return (
    <div
      ref={ref}
      className={`feature-card reveal ${ isVisible ? 'is-visible' : '' }`}
    >
      <h3>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

One thing to watch: if you're using React Server Components (RSC) in Next.js 14+, this hook must live in a client component — it uses useEffect and browser APIs. Mark it 'use client' or wrap it in a boundary. The CSS can stay global, no issues there.

Staggering Multiple Children

When you have a grid of cards or a list of items, you want them to reveal sequentially rather than all at once. The cleanest approach is a parent-level observer that stagger-animates the children by setting incremental animation-delay values.

import { useEffect, useRef } from 'react';

export function StaggerGrid({ items }: { items: string[] }) {
  const containerRef = useRef<HTMLUListElement>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const children = Array.from(container.children) as HTMLElement[];

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;

        children.forEach((child, i) => {
          child.style.animationDelay = `${i * 60}ms`;
          child.classList.add('is-visible');
        });

        observer.unobserve(container);
      },
      { threshold: 0.1 }
    );

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

  return (
    <ul ref={containerRef}>
      {items.map((item) => (
        <li key={item} className="reveal">
          {item}
        </li>
      ))}
    </ul>
  );
}

That 60ms stagger per item is a sweet spot. At fewer than 40ms it's so fast the stagger is barely perceptible. Above 100ms per item and your last card in a grid of 8 waits 480ms before animating — that's annoying. Dial it based on how many items you have: fewer items, longer stagger; more items, shorter stagger.

Quick aside: if you're building something more visual and interactive — like the Empire UI's style demos or a component showcase page — consider combining this stagger approach with the blur-to-sharp technique. Add filter: blur(4px) to the hidden state and animate it to filter: blur(0) alongside the opacity. It's subtle, but it makes reveals feel premium rather than just functional.

.reveal {
  opacity: 0;
  transform: translateY(24px);
  filter: blur(4px);
}

@keyframes fadeUpBlur {
  from {
    opacity: 0;
    transform: translateY(24px);
    filter: blur(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

.reveal.is-visible {
  animation: fadeUpBlur 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}

Performance Gotchas and When to Reach for CSS scroll-driven

This hybrid approach is fast, but there are a few ways to shoot yourself in the foot. First: don't animate width, height, top, left, or any property that triggers layout. Stick to opacity and transform. If you're not sure whether a property triggers layout, check the CSS animation performance article — it has a breakdown.

Second: don't create a new IntersectionObserver instance per element. Observers are cheap but not free. One observer can handle hundreds of targets — just call .observe(el) on each one. The pattern of instantiating an observer per component (which you'll see in a lot of React tutorials) adds overhead you don't need.

That said, there's now a native CSS alternative worth knowing about. CSS scroll-driven animations landed in Chrome 115 and let you tie animations directly to scroll position without any JavaScript at all. The animation-timeline: view() property is genuinely cool for continuous scroll-linked effects. But for discrete reveal-on-enter animations, the Intersection Observer approach is still more widely supported and gives you more control over the timing.

In practice, for production sites targeting a broad browser matrix as of 2026, the IO hybrid is the safer bet. Scroll-driven animations don't have full Firefox support until Firefox 128, and your analytics will tell you if that matters for your audience. For internal tools or Chrome-only dashboards, go native scroll-driven — it's cleaner. For public-facing marketing sites, stick with this pattern.

If you want to see these effects applied to real component designs, browse the Empire UI component library — a lot of the demo sections use exactly this reveal technique. And if you're building style-specific UIs, the glassmorphism generator is a good place to generate the background styles that sit underneath your animated content.

FAQ

Can I use this with Framer Motion or GSAP instead of pure CSS @keyframes?

Yes — Intersection Observer is framework-agnostic. Instead of adding a class, fire your gsap.from() call or set a Framer Motion animate prop inside the observer callback. The IO part stays identical; you just swap out the CSS animation trigger.

Why does my element flash visible for a frame before hiding when the page loads?

You're applying the .reveal class via JavaScript after the DOM is painted. Add the CSS for .reveal (opacity: 0, transform: translateY(32px)) globally in your stylesheet before the JS runs, not through a class toggle. The element should start hidden via CSS, not get hidden by JS after load.

Does Intersection Observer work inside scroll containers (overflow: scroll divs), not just the window?

It does. Pass root: containerEl as an option to the IntersectionObserver constructor instead of leaving it null (which defaults to the viewport). Everything else stays the same.

What threshold value should I use — 0, 0.1, 0.5?

0.1 to 0.2 is the sweet spot for most reveal effects. A threshold of 0 triggers the moment even 1px is visible, which often fires too early on fast scrolls. Above 0.5 means the element needs to be half in view before triggering, which can miss the reveal entirely on short elements.

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

Read next

Scroll Reveal Animation in React: AOS vs Framer Motion vs Intersection ObserverIntersection Observer in React: Scroll-Triggered Animations the Right WayAnimated Number Counter in React: Stats That Count Up on ScrollInfinite Scroll in React: Intersection Observer, React Query, Virtualization