Skip to main content
Contact
Engineering

The zero-JS reveal: scroll animation without a library

Scroll-driven animation is a native CSS feature now. Here is how far the platform gets you before a script is genuinely the right answer — and how to tell when you have crossed that line.

  • Amara Osei
3 min read

Every project reaches the moment where someone asks for content to fade in as you scroll to it. The reflex is to reach for an animation library, and for a decade that reflex was correct. It is not any more.

The ladder

Before shipping a byte of JavaScript for motion, there are three platform rungs worth climbing.

Native scroll-driven animation handles reveal-on-scroll with no script at all: an element declares a timeline tied to its own position in the viewport, and the browser scrubs the animation against scroll. @starting-style covers enter and exit transitions for top-layer elements — dialogs, popovers — including the exit, which was the part that always needed a listener. View transitions morph a shared element across navigations for the cost of one attribute.

Most motion briefs are fully covered by those three. The interesting question is what the remaining ones have in common.

When a script is actually earned

A scroll timeline scrubs. That is its defining property and also its limit: the animation’s progress is a pure function of scroll position, so it plays backwards when the reader scrolls up, and it has no notion of “this already happened”.

If the brief says the motion is a one-shot tween — a fixed duration, a named easing curve, a stagger between siblings, and no rewinding — that is not a scroll timeline with extra steps. It is a different animation, and no amount of clever CSS turns one into the other.

The second case is coupling. A scroll timeline cannot tell a sibling element what it is doing. If a progress line has to draw down a page and each card has to reveal at the exact moment the line reaches it, both facts have to come from the same numbers on the same frame, or the two drift and the section reads as two unrelated animations instead of one gesture.

Keeping the script small

When you do cross the line, keep the script to the part that genuinely needs it:

  • The trigger — one IntersectionObserver, shared across every element that reveals.
  • The queue position — an index written onto each child as a custom property.

Everything else stays in CSS. Duration, easing, stagger, the reduced-motion guard: all of it in a sidecar stylesheet, so restyling the motion never means reading JavaScript.

[data-reveal-item] {
  animation: rv-rise 0.7s cubic-bezier(0.215, 0.61, 0.355, 1) both;
  animation-delay: calc(var(--rv-delay) + var(--rv-i) * var(--rv-stagger));
}

That split has a useful side effect: the whole thing degrades to “the content is simply there” in three separate failure modes — no JavaScript, reduced motion, or the site’s own animation switch turned off.

The part that is not optional

prefers-reduced-motion is a user need, not a design preference, and it is worth saying plainly that a global guard does not cover scroll-driven animation. Those are progressed by scroll position rather than time, so zeroing durations does nothing to them. Anything driven by a scroll timeline needs its own opt-out, on the element.

Amara Osei

Founder and principal engineer. Writes about the parts of a build that don't show up in a demo — motion, accessibility, and the cost of a shortcut two years later.

More from Amara Osei
All posts

Ready to get off the ground?

Tell us what you're building. We'll tell you how we'd approach it — no pitch deck required.

Book a call