Reveal on Scroll Without IntersectionObserver
Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.
The problem
The standard reveal-on-scroll implementation creates an IntersectionObserver, watches every candidate element, adds an is-visible class when one crosses a threshold, and lets CSS animate the rest. It is a good pattern and it works — but it carries a script that has to load, elements that have to be registered, a class that has to be toggled, and a state machine that has to be reasoned about when elements are added or removed dynamically.
More subtly, it produces a binary reveal. The element is either revealing or revealed; the animation runs on a clock once triggered, so it can complete while the element is still half off-screen, or start before the user has really seen it. Scroll speed and animation duration are unrelated, which is why observer reveals feel disconnected on a fast flick.
Root cause: an observer reports a crossing, a timeline reports a position
IntersectionObserver answers a yes/no question at a threshold. Once it fires, the animation is time-based and knows nothing further about scroll.
A view timeline answers a continuous question: how far through its pass across the scrollport is this element? That value is available on the compositor, updates with the scroll itself, and can be mapped onto any part of the animation. The reveal is therefore tied to position rather than to a clock — scroll slowly and it fades in slowly, scroll quickly and it is already complete when it lands.
The mechanism is per-element by construction. animation-timeline: view() resolves against the element’s own intersection with its scrollport, so a hundred cards each get their own timeline without a single declaration naming any of them, and without any registration step. Adding a card to the DOM adds a working reveal; removing one removes nothing else.
Step-by-step resolution
Production code pattern
/* Intent: cards fade and rise as they enter the viewport, finishing well
before they reach the middle of the screen, with no JavaScript at all. */
.reveal {
opacity: 0;
translate: 0 16px;
animation: reveal-in linear both;
animation-timeline: view();
animation-duration: auto;
/* Start as the element's top edge enters, finish a third of the way up. */
animation-range: entry 10% cover 35%;
}
@keyframes reveal-in {
to { opacity: 1; translate: 0 0; }
}
/* Accessibility gate: no travel and no scroll linkage — the content is simply
present. This is the state most reduced-motion users should get. */
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
animation-timeline: auto;
opacity: 1;
translate: 0 0;
}
}
/* Progressive enhancement: engines without scroll-driven animation support
must not leave the content at opacity 0. */
@supports not (animation-timeline: view()) {
.reveal { opacity: 1; translate: 0 0; }
}
Rendering Impact:
opacity+translate— composite only. The timeline is resolved per element during style; every frame afterwards is a compositor-side intersection read, with no observer callback and no class mutation on the main thread.
The @supports block is not optional. An element whose visible state depends entirely on an animation that never runs is invisible content — the most damaging failure mode in this whole area, because it looks like a blank page rather than like a missing effect. The rule is simple: the resting styles must be the visible state whenever the animation cannot run.
Verification checklist
Constraints and trade-offs
- A view timeline animates but does not notify, so impression logging or lazy fetching still needs an observer.
- Reversal on scroll-up is the default; a one-way reveal needs a small script to freeze the end state after first completion.
- Very short elements may never satisfy a
containrange, so a reveal keyed to it never completes. - Each element resolves its own timeline, which is correct but means a shared “reveal all together” effect needs a named timeline instead.
- Non-linear easing on the timeline distorts the position mapping, so shaping belongs in the keyframes.
Frequently asked questions
Does a view timeline reverse when the user scrolls back up?
Yes, and that is usually the right behaviour: progress is a function of position, so scrolling back retraces it. If a reveal must happen only once, keep a small script that adds a class on first completion and swaps the timeline for a static end state.
What is the difference between entry, cover and contain ranges?
cover spans the whole pass from the element first touching the scrollport to fully leaving it. entry covers only the arriving quarter, exit only the leaving quarter, and contain the stretch where the element is fully visible. A reveal usually wants entry, or cover with a narrow percentage window.
Is the observer version ever better?
When the reveal has to trigger side effects — logging an impression, starting a fetch, playing a sound — the observer is still the right tool, because a timeline animates but does not notify. The two coexist happily: the timeline does the motion, the observer does the bookkeeping.
Making a reveal happen only once
The reversal behaviour is correct for most content and wrong for a few cases — an element that should stay revealed after the user has seen it, most obviously anything with an entrance that would be distracting to replay while scrolling back up a long page.
There is no declarative “run once” for a scroll timeline, because progress is a pure function of position and position can go backwards. The minimal solution keeps the timeline for the reveal and swaps it out on completion:
// Intent: let the timeline do the reveal, then freeze the end state so
// scrolling back up does not replay it.
const revealed = new WeakSet();
for (const el of document.querySelectorAll('.reveal')) {
for (const animation of el.getAnimations()) {
animation.finished.then(() => {
if (revealed.has(el)) return;
revealed.add(el);
el.classList.add('is-revealed'); // static end state in CSS
animation.cancel(); // release the timeline binding
}).catch(() => {});
}
}
The corresponding CSS gives .is-revealed the end values statically, so the element holds its revealed appearance with no animation attached at all. The cost is a script, which is what the timeline approach was avoiding — so it is worth asking first whether the reversal is genuinely a problem. On a page where sections are read once and scrolled past, it never becomes visible.
Related
- Scroll-Driven Animation Patterns — the parent guide on
scroll()andview()timelines - Scroll Timeline Scoping & Ranges — the range phases in detail
- Driving Animations with Scroll Timeline Polyfills — the observer-based fallback where support is missing