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: 0unconditionally, 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
marginortop, or reveals content that wasdisplay: none, shifting everything below it. - Motion sickness. Large translations and parallax ignore the user's
prefers-reduced-motionsetting.
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.
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
<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
.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
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:
<article class="reveal is-revealed">Hero card — never hidden</article>
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.
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.
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:
// 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); };
}
// 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
// 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.
Related
- Fade In on Scroll with IntersectionObserver and CSS — the minimal robust version
- Staggered Reveal Animations for Card Grids — ordering many items that enter together
- CSS Scroll-Driven Animations vs IntersectionObserver — choosing the engine
- Respecting prefers-reduced-motion in Scroll Reveals — motion-safe defaults
- Lazy Loading Images & Media — the same one-shot mechanism
↑ Back to Implementation Patterns for Viewport & Resize Tracking