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
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
}