Pattern 01 — IntersectionObserver

Reveal on scroll

The reveal is the workhorse of scroll animation: content waits, slightly displaced and transparent, until the reader brings it into view — then it settles into place. Done well it gives a page rhythm, like paragraphs taking a breath before speaking. Done badly it is a slideshow you cannot skip.

Everything on this page is driven by one observer function of about thirty lines. It never animates anything itself; it only flips a class. The motion — distance, direction, duration, easing, stagger — is entirely CSS, which keeps the JavaScript boring and the animation cheap.

Demo 01 — Fade-up, the default

Enter low, land settled

A 24px rise with a decelerating curve reads as "arriving". Distances beyond ~40px start to read as "flying", which gets tiring by the third section.

Opacity and transform only

Both properties skip layout and paint — the browser composites them on the GPU. Animating top, margin or height here would force reflow on every frame.

Content is never hostage

Every hidden state is scoped under a .js class added by a one-line script in the head. Scripts blocked? Nothing hides. Reduced motion? Reveals become crossfades.

Why an observer, not a scroll listener

A scroll handler runs on every scrolled pixel and forces you to measure elements yourself — and calling getBoundingClientRect in a hot loop invites layout thrash. IntersectionObserver inverts the deal: the browser watches geometry off the main thread and calls you only at the crossings you asked for. For reveals, which care about a handful of moments rather than continuous progress, it is the right tool almost every time.

Demo 02 — Stagger a group

tile 0
tile 1
tile 2
tile 3
tile 4
tile 5

80ms between siblings. Under ~50ms a stagger blurs into a single blob; over ~120ms the tail of the group feels late for the meeting.

Demo 03 — Direction as data

data-reveal="up"

The default. Upward entrances agree with reading direction: new content comes from where you are headed.

data-reveal="down"

Falling in from above. Use sparingly — it reads as an interruption, which is occasionally exactly the point.

data-reveal="left"

Slides in from the left edge. Pairs of left/right cards can imply dialogue or comparison.

data-reveal="right"

The mirror. Note the CSS: only the STARTING transform differs; the visible state is one shared rule.

Choreography is information

Direction, order and timing are not decoration — they are claims about structure. A staggered grid says "these are siblings". A left/right pair says "compare us". Everything rising the same way says "one list, keep going". Pick the claim first, then the animation; motion that contradicts the content's structure is how pages end up feeling busy without feeling alive.

Demo 04 — Once vs repeat

Once (default)

After revealing, the observer calls unobserve on this card: zero further work, and content never blinks out while the reader scrolls back up. The right default for prose and product pages.

Repeat (data-reveal-repeat)

This card resets when it fully leaves the viewport and replays on return. Good for ambient, gallery-like pages; risky around text people actually read.

Scroll well past this section, then come back up — only the right card replays.

Demo 05 — Threshold & rootMargin, the tuning knobs

threshold: 0.15 means "call me when 15% of the element is visible" — it stops slivers at the screen edge from counting as arrivals. rootMargin is stranger and more useful: it insets (or outsets) the rectangle intersections are tested against. A bottom margin of -10% moves the finish line up, so elements reveal a beat after entering rather than exactly at the edge:

Default line (-10%)

Reveals just above the viewport edge. This is the house default: perceptible but prompt.

Late line (-35%)

Same function, second instance, rootMargin of -35%: this card waits until it is a third of the way up your screen.

Scroll slowly here — the right card fires noticeably later than the left one.

The whole engine

This is the complete, unabridged script running every demo above — called once for the default line and once for the late-line variant. (The only other JS on the page is the one-line .js class gate in the head.)

/**
 * initReveals — one observer for every scroll-reveal it is given.
 *
 * Markup contract:
 *   data-reveal            fade-up (the default)
 *   data-reveal="left"     direction: up | down | left | right
 *   data-reveal-stagger    children delay off their --stagger index
 *   data-reveal-repeat     re-arms when it leaves the viewport
 *
 * The observer only toggles a class; all motion lives in CSS, where the
 * compositor can run it and prefers-reduced-motion can neutralise it.
 */
function initReveals(selector, { threshold = 0.15,
                                 rootMargin = '0px 0px -10% 0px' } = {}) {
  const targets = document.querySelectorAll(selector);

  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');

        // "Once" elements are finished: unobserve so the browser does
        // zero work for them on every future scroll.
        if (!entry.target.hasAttribute('data-reveal-repeat')) {
          observer.unobserve(entry.target);
        }
      } else if (entry.target.hasAttribute('data-reveal-repeat')) {
        // "Repeat" elements re-arm once fully out of view.
        entry.target.classList.remove('is-visible');
      }
    }
  }, { threshold, rootMargin });

  targets.forEach((el) => observer.observe(el));
  return observer; // callers can .disconnect() on teardown
}