Skip to content
Shenzhen · The Greater Bay Area · Earth

A Scroll Animation Must Hide Content After the Observer Attaches, Never in the Markup

The initial hidden state of a scroll animation is a decision about the clients that cannot run the script meant to reveal it. Keep that decision on the client, gate it behind the observer, and the no-JavaScript render becomes the finished state.

9 min read1,919 words
Design EngineeringWeb PerformanceNot yet translated.

Where does the hidden state live before the observer exists?

There is exactly one safe moment to hide a scroll-animated element, and it is after the observer that will reveal it has attached. Ship the hidden state in the class list the server writes, and every client that does not execute your script — retrieval crawlers, readers with scripting turned off, anyone whose bundle failed to parse — receives a document whose sections are present in the DOM and paint as nothing at all.

The mechanism is dull, which is why it survives review. A section ships as <div class="opacity-0 translate-y-2">, an effect constructs an IntersectionObserver, and a state change swaps in the visible classes once the element crosses the viewport. That is two decisions living in two files: hiding is decided on the server at render time, revealing is decided on the client after hydration. For a client that cannot hydrate, the outcome is not a degraded animation. The outcome is a page with the content removed.

I have shipped this, more than once. The instance I remember had the article index inside the reveal wrapper, and a runtime error in an unrelated analytics snippet aborted the module graph before any observer was constructed. Every card on that page sat at zero opacity. The markup was correct, the accessibility tree still exposed the text, and selecting that region and copying it produced the real words in the clipboard. Only the pixels were missing — a missing element reads as a bug, a transparent one reads as a design.

Content hidden in server-rendered markup is hidden from every client that cannot run the script meant to reveal it, so the initial state of a scroll animation has to belong to the client, applied after the observer attaches, and never written into the HTML.

Why does a hidden class in the markup keep passing review?

Because the environment you inspect it in is the one environment where it works. The dev server hydrates from a warm module cache on the same machine that rendered the document, so the gap between first paint and observer attachment is a frame you cannot perceive, and once the page settles it looks exactly like the design comp. The defect only appears under conditions that are absent from your desk: a cold load on a phone on a slow connection, a fetch that never executes script, a CSP rollout that blocks the module, a reader who asked their operating system for less motion.

The failure is a matrix rather than a matter of taste, and it is worth writing down once.

ClientHidden state written in the markupHidden state applied after the observer attaches
Retrieval and language-model crawlersSections absent from the text they retrieveFull text in the first response body
Reader with scripting disabledBlank sections, permanentlyFull content, no motion
Blocked bundle, CSP failure, extension interferenceBlank sections, permanentlyFull content
Cold load over a slow connectionBlank until hydration, then a fadeAt most one frame visible, then a fade
Reader with prefers-reduced-motion: reduceHidden until hydration, then an instant appearNever hidden in the first place
Print and PDF exportBlank, unless the observer fires for paperFull content, once the print reset exists

Two things follow. The expensive failures are permanent rather than transient: a crawler and a script-less reader never get a second chance when the network improves, because nothing in their session will construct the observer. And the animation is not the feature being protected — the content is. An entrance animation is a decoration on text that has to be readable whether or not the decoration runs.

How do you hide an element only after the observer is attached?

Three states, and the server emits none of them. pending is the server render and the hydration pass, and in that state the element has no hidden values applied at all. primed is set in the same effect that constructs the observer, one commit later, and only when the client has confirmed it can observe. shown is terminal: the observer disconnects on the first intersection, so nothing ever hides again — which matters for a reader who scrolls back past a section they already read.

The first piece is the media query, because reduced motion has to short-circuit the whole mechanism rather than only its duration.

'use client';

import { useSyncExternalStore } from 'react';

const QUERY = '(prefers-reduced-motion: reduce)';

function subscribe(onChange: () => void): () => void {
  const mql = window.matchMedia(QUERY);
  mql.addEventListener('change', onChange);
  return () => mql.removeEventListener('change', onChange);
}

/**
 * The server snapshot is `false` on purpose: the server cannot know the reader's
 * operating-system setting, and answering "yes, reduce" would strand the animation
 * off for everyone. Answering "no preference" means "do not hide anything yet",
 * which is the safe default in both directions.
 */
export function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  );
}

That asymmetry — the server sends a default it cannot verify, and the only defensible default hides nothing — is the same asymmetry that decides which theme a page renders in, and it reappears in every feature where a reader preference lives on the client while the response body is built on the server.

The hook itself is where the ordering guarantee is enforced. Note that the observer is constructed before any state change, that the media query is a dependency rather than a one-time read, and that the bail-out paths leave the element visible.

'use client';

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

import { useReducedMotion } from './use-reduced-motion';

export type RevealState = 'pending' | 'primed' | 'shown';

export function useReveal(): { ref: RefObject<HTMLElement | null>; state: RevealState } {
  const ref = useRef<HTMLElement | null>(null);
  const reduceMotion = useReducedMotion();
  const [state, setState] = useState<RevealState>('pending');

  useEffect(() => {
    // Nothing is hidden under reduced motion, so there is nothing to observe.
    if (reduceMotion) return;

    const node = ref.current;
    if (node === null || typeof IntersectionObserver === 'undefined') return;

    // Hide only now: one commit after mount, once we know we can observe.
    setState('primed');

    const observer = new IntersectionObserver(
      (entries) => {
        if (!entries.some((entry) => entry.isIntersecting)) return;
        setState('shown');
        observer.disconnect(); // one-shot: scrolling back never re-hides content
      },
      // Start slightly before the element reaches the viewport, so the motion reads
      // as the page settling rather than as content arriving late.
      { threshold: 0.01, rootMargin: '0px 0px -12% 0px' },
    );

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

  // A reader who switches on reduced motion mid-session gets the finished state
  // immediately, even if the observer had already primed this element.
  return { ref, state: reduceMotion ? 'shown' : state };
}

The consuming element maps pending to no attribute at all, which is the whole trick in one line: data-reveal={state === 'pending' ? undefined : state}. React omits an attribute whose value is undefined, so the server-rendered bytes contain no hidden state for a client to inherit. The stylesheet then needs one detail that is easy to get wrong: the transition has to be declared on the base selector. If the transition lives only on the hidden state, removing that state removes the declaration in the same style recalculation that changes the values, and the property change has nothing to transition with — the element snaps.

There is one visible cost, and I would rather name it than hide it. Between hydration and the effect running, the element is painted at its final position. On a cold load that is one frame of fully visible content before it fades. Where the animated element is a hero above the fold, I prime in a layout effect instead, because layout effects flush before the browser paints; everywhere else the one frame is cheaper than the extra machinery.

Can the CSS do the hiding with no JavaScript at all?

Partly, and when it can, that is the better answer: a design that needs no script cannot be broken by a script. Scroll-driven animations let viewport position drive the animation directly, and the support gate keeps the no-JavaScript guarantee intact, because the hidden values live inside @supports and an engine that cannot run the animation never receives them.

/* Default is the finished state. Nothing outside the @supports block may hide it. */
[data-reveal] {
  opacity: 1;
  translate: none;
  /* Declared on the base selector: a transition removed at the same moment its
     target values change has nothing left to animate with. */
  transition: opacity 700ms var(--ease-out-expo), translate 700ms var(--ease-out-expo);
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-reveal] {
      animation: reveal-in linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 35%;
    }
  }
}

@keyframes reveal-in {
  from { opacity: 0; translate: 0 0.5rem; }
  to { opacity: 1; translate: none; }
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal] { opacity: 1; translate: none; transition: none; }
}

@media print {
  [data-reveal] { opacity: 1; translate: none; transition: none; animation: none; }
}

Two caveats come with the CSS path. Staggering a list is not animation-delay, because a scroll timeline has no seconds to delay in; each item needs its own animation-range, supplied as an index-derived custom property, which is more CSS than a stagger prop. And older engines never animate at all, which is the correct trade: a static page beats a blank one. I carry no browser version table here: the gate is evaluated by the client at parse time, and it is the only compatibility statement the design depends on.

How do you check the served bytes rather than the hydrated DOM?

The check has to run against what the server sent, because that is what a script-less client receives and what a crawler indexes. A browser with scripting enabled shows you the happy path however the markup is written.

# The document exactly as served: no cookies, no scripting, no hydration.
curl -sS http://localhost:3000/ -o /tmp/served.html

# A reveal that hid itself in the markup appears here. It must be zero. Note that
# grep -c prints 0 and still exits 1, so the `|| true` is not decoration.
grep -c 'data-reveal="primed"' /tmp/served.html || true

# The prose has to be inside those bytes, not assembled after hydration.
grep -o '<p[^>]*>[^<]\{60,\}' /tmp/served.html | wc -l

The second assertion is the one that catches real regressions, because it fails when a section moves into a client component that fetches its own text. I run the first as a build assertion: if the primed attribute count is not zero in the static output, the deploy stops. Two manual passes are worth keeping: disable JavaScript and load the page cold, then emulate reduced motion and load it again. Both renders should be the finished state, and the difference between them invisible.

What does hiding after the observer attaches cost, and when is it the wrong call?

It costs a little complexity in the component, and it can cost you a paint you are being measured on. If the animated element is the largest contentful paint candidate, an entrance transition pushes the paint Chrome records later than the byte arrived, so a 700ms fade is a 700ms penalty on a number you will be asked about. Animating a child of the hero instead of the hero itself keeps the paint early and the motion visible.

SituationHide after the observer attachesWrite the hidden state in the markup
Public article or landing sectionCorrect: the first response body is the finished stateBlank sections for crawlers and script-less readers
Hero that is the largest contentful paint candidateTransition delays the measured paint; animate a child insteadSame delay, plus blank frames when the bundle is slow
Authenticated app behind a loginWorks, though no crawler will read itDefensible: fewer frames of visible-then-hidden content
Printed or exported pagesNeeds the print reset aboveBroken already, and noticed later
A list of four hundred rowsShare one observer and key state per elementNo observer cost, and no readers either

The other honest cost is observer count. Twelve observers on a marketing page is nothing; four hundred for a long table is a real allocation on a mid-range phone, and the fix is one observer with a WeakMap<Element, () => void> of callbacks rather than one per row. I would reach for that before an animation runtime: shipping a motion library to every visitor, including the ones that never run it, is a poor trade for a fade a stylesheet can express.

This is the wrong approach in some places. In an application where content is already behind authentication and the reader is a signed-in operator, hiding in the markup costs nothing anyone will notice, and the extra state machine is effort spent on an audience that does not exist. In a document people print, read with a screen magnifier at 400 percent, or cite in a formal filing, an entrance animation is a small liability with no upside, and the right number of scroll-triggered animations is zero. And if the reveal is doing real work — signalling that a long page has ended, or grouping a comparison into one readable block — the problem is layout, not motion, and a heavier transition will not fix it.

One last measurement argues the same instinct from the other direction. The home page renders 141 character-reveal spans, and as inline utilities that was 141 identical 42-byte class attributes — about 5.9KB of raw HTML on one page. They live in the stylesheet now, which is the same trade as the state machine above: stop the markup from carrying decisions it cannot enforce.

What should you change on the next animated section you ship?

Take the one section with the worst scroll-entrance implementation and delete its hidden state from the markup before you touch anything else: no opacity-0 in the server-rendered class list, no hidden wrapper around a server component, and a terminal state that disconnects the observer on first intersection so scrolling back up never hides text a reader has already read. Then load that page with JavaScript disabled and confirm you are looking at the finished design rather than its skeleton — that single check catches the whole class of bug, and it takes four seconds. If the page passes, the remaining risk is almost always the paint you are measured on and the printed output, and those two are worth an afternoon each before they are worth any more animation.

Keep reading

More in Design Engineering

Ready to build a system?[ Book a Call ]