A reveal animation has two jobs that are easy to blur together: deciding when an element has entered the viewport, and animating it once it has. IntersectionObserver is ideal for the first, CSS for the second — and most broken reveal effects come from asking either one to do the other's job.

Concept Framing

Scroll-triggered reveals are the most common use of IntersectionObserver on marketing sites, documentation and product pages: cards fade up, images slide in, headings unblur. They sit inside the Implementation Patterns section alongside lazy loading because they share the same mechanism — a one-shot crossing that triggers a state change — with a different payload.

The pattern looks trivial and still goes wrong in four recurring ways:

  • Content hidden without JavaScript. The stylesheet sets opacity: 0 unconditionally, and a script error or a slow bundle leaves the page blank.
  • Late reveals. The crossing fires at the viewport edge and the callback task is delayed, so users see an empty slot before the fade starts — the timing issue explained in where IntersectionObserver callbacks run.
  • Layout shift. The animation moves elements with margin or top, or reveals content that was display: none, shifting everything below it.
  • Motion sickness. Large translations and parallax ignore the user's prefers-reduced-motion setting.

Designing the pattern around those failure modes produces a reveal that is robust by default: visible without JS, pre-armed ahead of the viewport, transform-only and motion-aware.

The Division of Labour in a RevealFour boxes. The page renders content visible by default. A script adds a class to the root confirming JavaScript is running, which lets CSS hide unrevealed items. IntersectionObserver, with a positive bottom margin, marks each item revealed shortly before it reaches the viewport and stops observing it. CSS transitions opacity and transform only, and skips motion under reduced-motion.Visible by defaultHTML and CSS aloneshow everythingOpt-in classscript adds .js-revealto the rootObserver decideswhenone-shot crossing,pre-armed marginCSS decides howopacity + transform,motion-aware

Spec / Signature Reference Table

The options that matter for reveals are a small subset of IntersectionObserverInit, plus the CSS properties that keep the animation compositor-friendly.

Setting Recommended value Purpose
root null reveals are relative to the viewport
rootMargin '0px 0px -10% 0px' or '0px 0px 15% 0px' negative: reveal once well inside; positive: pre-arm before entry
threshold 0 or 0.15 one crossing is enough; more thresholds only add tasks
unobserve() after reveal always one-shot; no further tasks for the element
CSS transition-property opacity, transform composited; no layout or paint of neighbours
CSS transition-duration 200–500 ms long enough to register, short enough not to delay reading
@media (prefers-reduced-motion: reduce) opacity only, or none respects vestibular sensitivity
will-change omit, or set only during animation permanent layers cost memory

Step-by-Step Implementation

Step 1: Mark up content that is visible without script

HTML
<section class="features">
  <article class="reveal"></article>
  <article class="reveal"></article>
  <article class="reveal"></article>
</section>

The .reveal class is only a hook. Nothing in the default stylesheet hides it.

Step 2: Hide unrevealed items only once script has opted in

CSS
.js-reveal .reveal:not(.is-revealed) {
  opacity: 0;
  transform: translateY(16px);
}
.reveal {
  transition: opacity 400ms ease-out, transform 400ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .js-reveal .reveal:not(.is-revealed) { transform: none; }
  .reveal { transition: opacity 200ms linear; }
}

The opt-in class is added by the same script that creates the observer, so if that script never runs, nothing is ever hidden.

Step 3: Observe once, reveal once

TypeScript
export interface RevealConfig {
  selector: string;
  revealedClass?: string;
  rootMargin?: string;
  threshold?: number;
}

export function initReveal({
  selector,
  revealedClass = 'is-revealed',
  rootMargin = '0px 0px -10% 0px',
  threshold = 0,
}: RevealConfig): () => void {
  if (!('IntersectionObserver' in window)) return () => {};   // leave everything visible

  document.documentElement.classList.add('js-reveal');

  const io = new IntersectionObserver(
    (entries, obs) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        entry.target.classList.add(revealedClass);
        obs.unobserve(entry.target);          // one-shot
      }
    },
    { rootMargin, threshold },
  );

  document.querySelectorAll(selector).forEach((el) => io.observe(el));
  return () => io.disconnect();
}

Step 4: Reveal what is already on screen immediately

The first delivery after observe() reports every element's current state, so items above the fold are revealed in the first frame after the observer runs. To avoid even that one frame of hidden content on the hero, exclude above-the-fold items from the selector or add the revealed class server-side:

HTML
<article class="reveal is-revealed">Hero card — never hidden</article>

Reveal Implementation, Step by StepFour steps. Mark up content with a hook class and no hiding. Hide unrevealed items only under a root class the script adds. Observe with a one-shot callback that adds the revealed class and unobserves. Pre-reveal above-the-fold items server-side so the first frame never hides them.1Hook class only.reveal marks candidates; nothing is hidden by default.2Opt-in hiding.js-reveal on the root enables the hidden starting state.3One-shot observerAdd .is-revealed on the first intersection, then unobserve.4Above the foldShip hero items already revealed so the first paint shows them.

Threshold / Configuration Variants

The rootMargin choice changes the character of the effect more than any animation curve does.

Variant rootMargin threshold Feels like Risk
Pre-armed 0px 0px 15% 0px 0 content is already arriving as it enters animation partly off-screen on fast scroll
At the edge 0px 0 classic "fade in as it appears" blank flash on congested pages
Inset 0px 0px -10% 0px 0 reveal once clearly in view reads as late on short viewports
Proportional 0px 0.25 large items reveal when a quarter is shown very tall items may never reach it
Horizontal carousel 0px -10% 0px -10% with carousel as root 0 slides reveal as they snap in needs the carousel as root

The proportional variant has a trap: an element taller than four times the viewport can never be 25% visible, so it never reveals. Use a threshold of 0 with an inset margin for anything that can be tall.

Pre-Armed Versus Inset MarginsA viewport with a positive bottom root margin drawn as a dashed extension below it. A card inside the extended area is already revealed before it scrolls into the viewport. A card just inside the viewport is revealed. A card further below, outside the margin, is still waiting.card in view — revealedcard in the margin — revealed earlycard below the margin — waitingSolid blue frame: viewport (root). Dashed frame: rootMargin 40px.A positive bottom margin starts the transition off-screen, so a congested main thread no longer produces a blank slot.

Edge Cases & Gotchas

Revealing hidden (display: none) content. An element with no layout box never intersects. Reveals must start from opacity: 0, never from display: none or visibility: hidden on an ancestor.

Anchored navigation. Jumping to #pricing lands mid-page; every element between the top and the anchor is skipped over, and those above the new viewport are never revealed until the user scrolls back up. For long pages, reveal everything above the current scroll position once, on load, with a single getBoundingClientRect() pass.

Print and reader modes. Print stylesheets and browser reader views may keep your opt-in class but never scroll. Reset the hidden state in @media print.

Find-in-page. Ctrl+F scrolls to matches without the user scrolling, which is fine — the observer fires — but text in unrevealed items is still searchable and highlighted while invisible for a frame. That is another argument for pre-arming.

Nested scroll containers. Content inside a scrollable panel — a modal body, a sidebar, a horizontally scrolling carousel — is clipped by that panel. With root: null the observer still measures against the viewport, and an item hidden inside the panel's overflow can be reported as intersecting. Pass the panel as root for reveals that live inside it, and create one observer per scroll container rather than one per item.

Client-side route changes. Single-page apps replace content without reloading, so a reveal module initialised once at startup never sees the new route's elements. Register targets when components mount (the framework patterns below do this naturally) or re-run the selector scan after each navigation, disconnecting the previous observer first so detached elements are not retained.

Layout shift from transforms. Transforms do not shift layout, so they do not count towards CLS. Animating margin-top, top or height does, and the layout-shift observer guide will show it.

Performance Budget for Reveals

Reveals are decoration, so their cost should be close to zero. It is worth being precise about where that cost goes, because a page with two hundred reveal targets can still accidentally spend real time on them.

Construction. One observer with two hundred targets costs a single allocation plus two hundred observe() calls, each of which is cheap. Two hundred observers — one per component — costs two hundred allocations and two hundred callback closures, measurable on a mid-range phone during the mount of a long page. The one observer vs many benchmark quantifies it.

Per-frame intersection work. The browser computes intersections for every observed target during each rendering update. Unobserving after the reveal shrinks that set as the user scrolls, so by the end of the page the observer is doing no work at all. Leaving targets observed keeps the full set in the computation forever.

Callback work. A reveal callback should do nothing but add a class and unobserve. Reading layout, calling into a framework's state system, or logging analytics inside it multiplies the cost by the number of targets in each batch — and a fast fling can deliver a dozen at once.

Animation work. Opacity and transform transitions are composited; the main thread sets the class and the compositor does the rest. Transitions on filter: blur() are composited in most engines but are expensive to rasterise on low-end GPUs; box-shadow transitions repaint every frame. Keep blur reveals for a handful of hero elements, not for every card.

A useful sanity check is to scroll the whole page with the Performance panel recording and filter the main-thread activity to your reveal module. On a well-built page, the total is a few milliseconds for the entire scroll.

Where Reveal Cost Goes on a 200-Card PageA bar chart of main-thread time for a full scroll of a two hundred card page. One observer per card costs about eighteen milliseconds at mount. A shared observer costs about two. Keeping targets observed after reveal adds about twelve milliseconds of intersection work over the scroll, while unobserving adds under one. Reading layout inside the callback adds about thirty milliseconds.Full scroll of a 200-card page, mid-range phoneone observer per card, mount~18 msshared observer, mount~2 msnever unobserving, scroll~12 msunobserve after revealunder 1 mslayout reads in the callback~30 ms

Framework Integration Patterns

In component frameworks, the one-shot observer belongs in a shared module, not in every component. A Svelte action, a Vue directive, a React ref callback and an Angular directive can all call into the same observeOnce helper:

TypeScript
// shared/reveal-pool.ts — one observer for the whole page
const callbacks = new WeakMap<Element, () => void>();
let io: IntersectionObserver | null = null;

export function observeOnce(el: Element, onReveal: () => void): () => void {
  io ??= new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      callbacks.get(e.target)?.();
      callbacks.delete(e.target);
      io!.unobserve(e.target);
    }
  }, { rootMargin: '0px 0px 15% 0px' });
  callbacks.set(el, onReveal);
  io.observe(el);
  return () => { callbacks.delete(el); io?.unobserve(el); };
}
TSX
// React: a ref callback that reveals by class, without a state update or re-render.
export function Reveal({ children }: { children: React.ReactNode }) {
  const ref = React.useCallback((el: HTMLDivElement | null) => {
    if (!el) return;
    return observeOnce(el, () => el.classList.add('is-revealed'));   // React 19 ref cleanup
  }, []);
  return <div className="reveal" ref={ref}>{children}</div>;
}

Toggling a class directly avoids re-rendering the component for a purely visual change. The shared pool follows the same reasoning as shared observer pooling: one observer, a WeakMap of per-element callbacks.

Debugging Checklist

  • Disable JavaScript and reload — every .reveal
TypeScript
// Console: list reveal targets that are on screen but still hidden.
[...document.querySelectorAll('.reveal:not(.is-revealed)')].filter((el) => {
  const r = el.getBoundingClientRect();
  return r.bottom > 0 && r.top < innerHeight;
}).forEach((el) => console.warn('stuck hidden', el));

FAQ

Should reveals be one-shot or repeat every time an element enters?

One-shot for content. Re-hiding content as it leaves the viewport and animating it again when it returns turns every scroll into motion, which is tiring to read and harmful for motion-sensitive users. Repeat only for decorative elements where the animation is the point.

Is a scroll-driven CSS animation better than IntersectionObserver for reveals?

It can be, where supported: animation-timeline: view() runs on the compositor with no script at all. It animates continuously with scroll position rather than triggering once, so it suits parallax-like effects better than fire-and-forget fades. Many sites use scroll-driven animations with an observer-based fallback.

Why do my reveal animations cause layout shift?

Because they animate a layout property — margin, top, height — or reveal an element that previously had no box. Animate only opacity and transform, and keep the element in the layout the whole time.

How many observers should a page with many reveals use?

One. All reveals share the same root and margin, so a single observer with many targets is cheaper to construct and cheaper in memory than one per element.

Do reveal animations hurt Largest Contentful Paint?

They can, if the largest element above the fold starts at opacity zero, because LCP waits for it to become visible. Never hide the hero or the first screen of content behind a reveal.

Why do some reveal targets never appear after a route change in a single-page app?

Usually because the observer was created once for the initial page and the new route's elements were never registered with it, or because the old observer was disconnected on navigation and nothing created a new one. Register targets as components mount and unregister them as they unmount, rather than scanning the document once.

Can reveals and lazy loading share one observer?

They can if they want the same root margin, and sharing saves a little work. In practice lazy loading wants a much larger margin — hundreds of pixels — than a reveal does, so two observers with different options are usually clearer.


↑ Back to Implementation Patterns for Viewport & Resize Tracking