Shipping Scroll-Driven Animations Without Breaking Anything

Scroll-driven animations are the rare CSS feature where "it just degrades" is not automatically true. The failure mode is not a missing effect — it is an element that snaps straight to its final keyframe, which for a reveal means content that was supposed to fade in appears instantly, and for a progress indicator means a bar that is permanently full and lying to the reader. On top of that sits an accessibility obligation that is stronger than for ordinary animation, because motion welded to the scroll gesture is a known vestibular trigger. This page covers both halves of shipping safely: @supports (animation-timeline: scroll()) gating, and what the reduced-motion branch actually has to remove. It assumes the mechanics from the scroll-driven animations guide.


What an Unsupported Browser Actually Renders

Be precise about the failure, because the fix depends on it. Consider an ungated reveal:

.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

A browser without scroll-timeline support parses this declaration by declaration. animation-timeline and animation-range are unknown properties, so they are discarded at parse time — the cascade drops them silently, exactly as it drops any typo. But animation: reveal linear both is perfectly valid CSS that predates all of this. It is kept, and it runs on the document timeline.

Which duration? The shorthand did not declare one, so animation-duration resets to its initial value of 0s. A zero-duration animation with fill-mode: both completes on the first frame and holds its final keyframe forever. So the unsupported outcome is: the element appears immediately in its revealed state.

For a reveal, that is genuinely fine — arguably better than the enhancement. For anything where the final keyframe is not the correct resting state, it is a bug:

EffectFinal keyframeUnsupported result
Fade-and-rise revealopacity: 1; translateY(0)Correct — content simply appears
Progress barscaleX(1)Permanently full bar, actively misleading
Compressing sticky headerfully compressedHeader starts small and never grows
Parallax layermaximum offsetLayer sits at its extreme position, layout looks broken
Horizontal scroll sectionlast panel shownReader lands mid-sequence with no way back

The rule that falls out of this table: gate the enhancement whenever the final keyframe is not a sane static state. If it is, you can ship ungated and save yourself a block.

Gating a scroll-driven enhancement Base styles flow into a supports test, which branches into three outcomes: unsupported and static, supported with reduced motion, and supported with full scroll-driven motion. Three states you are shipping base styles: visible, static, no motion @supports (animation-timeline: scroll()) no support base styles stand reduce requested opacity only full support scroll-linked motion All three must be a usable page, not one design plus two accidents.

The Gating Pattern

The structure that works for every case is the same: declare the resting, correct-for-everyone state outside the block, and put only the timeline binding inside it.

/* 1. The state every browser gets. This must be a complete, usable design. */
.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 5px;
  transform-origin: 0 50%;
  /* Absent the enhancement there is no meaningful progress to show,
     so the bar is not rendered at all rather than rendered wrong. */
  display: none;
}

/* 2. The enhancement, applied only where the timeline can actually run. */
@supports (animation-timeline: scroll()) {
  .progress {
    display: block;
    background: #4361c8;
    animation: grow-progress linear;
    animation-timeline: scroll(root block);
  }

  @keyframes grow-progress {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
  }
}

Three details matter here.

Test the function, not the property name. @supports (animation-timeline: none) will pass in any engine that knows the property exists — including one that ships the property but not the scroll() timeline function. Always put a real timeline function in the test: scroll() or view(). Testing view() specifically is worth doing if your effect depends on it, since the two shipped together everywhere so far but the guarantee is per-value, not per-property.

Do not use the negated form as your only branch. @supports not (animation-timeline: scroll()) is legal, but writing both a positive and a negative block means maintaining two designs that must stay in sync. The base-plus-enhancement shape has exactly one thing that varies.

Keep @keyframes inside the block when the animation is only ever scroll-driven. Nested at-rules are allowed, and it keeps the enhancement in one place. If the same keyframe rule is shared with a time-based animation elsewhere, hoist it out.

The same discipline is documented on the layout side in feature detection with @supports; the difference here is only that the consequence of getting it wrong is louder.

When not to gate

Gating has a cost: the styles inside the block are invisible to anyone reading the base rules, and it is easy to leave a value stranded there. Skip it when the enhancement's end state is also the correct static state — which is true for the entire family of fade-and-rise reveals covered in scroll-triggered reveal animations, provided the element's own declared styles are the visible ones and the hidden state lives only inside the keyframes. Under those conditions an unsupported browser renders a normal page, which is the whole goal.

What about a polyfill?

There is a well-known JavaScript polyfill that implements scroll timelines using the Web Animations API and a scroll listener. It works. It also reinstates precisely the main-thread cost that scroll-driven animations exist to eliminate, and it does so on the browsers that are already the slowest path. For decorative motion this is a bad trade: you are shipping kilobytes of script and a scroll handler so that a minority of readers can see a fade they will not miss. Reach for it only when the effect is load-bearing for comprehension — a scroll-driven diagram that explains something, for instance — and even then, consider whether a static annotated version communicates the same thing more reliably. When motion genuinely must be identical everywhere, the honest choice is to write it in script deliberately rather than emulate a CSS feature.


The prefers-reduced-motion Obligation

This is not the same conversation as browser support, and conflating the two produces the common bug where an @supports block ships the reduced-motion branch nothing at all.

Scroll-linked motion is a stronger vestibular trigger than ordinary animation, for a mechanical reason. When a user scrolls, their vestibular and proprioceptive systems predict a specific relationship between the input and the visual field: content translates by the scroll distance, uniformly. A scroll-driven animation deliberately breaks that relationship — a parallax layer moves at 0.6× the scroll rate, a card slides up while the page slides down, an element scales while everything around it does not. That mismatch between predicted and actual optic flow is what produces dizziness, nausea, and disorientation in people with vestibular disorders. It is also why parallax specifically has the worst reputation of any web motion pattern.

WCAG 2.1's Success Criterion 2.3.3 (Animation from Interactions, Level AAA) addresses this directly: motion animation triggered by interaction can be disabled unless it is essential. Scrolling is an interaction. The reasoning is unpacked further in the WCAG motion success criteria page.

What the branch must remove:

EffectReduced-motion treatment
translate / translateY on scrollRemove entirely
scale on scrollRemove entirely
rotate on scrollRemove entirely
Parallax (differential layer rates)Remove entirely; layers scroll with the page
Scroll-hijacking / horizontal panelsRemove; restore normal vertical scrolling
opacity on scrollUsually safe to keep; consider a floor so nothing is fully hidden
Colour or background shift on scrollSafe to keep
Progress indicator width or scaleXKeep — it is information, not decoration

That last row is the one people get wrong most often, by wrapping the entire scroll-driven block in a no-preference query and thereby deleting a useful position indicator for the readers most likely to want it. Reduced motion means less movement, not less information. The distinction is developed at length in reducing motion preferences in CSS.

Here is the opacity-only variant scrolling — nothing traverses the visual field, and nothing ever drops to fully invisible:

Live demoThe reduced-motion-safe reveal
Scroll down: these cards change opacity only, never position — the variant to serve when the reader has asked for reduced motion.
.tile {
  animation: soften linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

/* Opacity floors at 0.35 rather than 0 so the text is never fully absent
   for a reader who cannot scroll it into range. */
@keyframes soften {
  from { opacity: 0.35; }
  to   { opacity: 1; }
}

Putting Both Guards Together

The two conditions are independent, so nest them rather than trying to express them as one test. Order matters for readability: gate on support first, then narrow by preference inside.

/* Base: visible, static, correct for everyone. */
.card {
  opacity: 1;
  transform: none;
}

@supports (animation-timeline: view()) {
  /* Default enhancement: full scroll-linked reveal. */
  .card {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }

  @keyframes reveal {
    from { opacity: 0; transform: translateY(28px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  /* Narrowed enhancement: keep the scroll link, drop the movement. */
  @media (prefers-reduced-motion: reduce) {
    .card {
      animation-name: reveal-soft;
    }

    @keyframes reveal-soft {
      from { opacity: 0.4; transform: none; }
      to   { opacity: 1; transform: none; }
    }
  }
}

Overriding only animation-name inside the media query is deliberate. The timeline, range, and fill mode are already correct; swapping the keyframe rule changes what is interpolated without duplicating the binding, so the two versions cannot drift apart. Verify the result by emulating the preference in DevTools rather than changing your operating system settings, and check that the page still reads correctly with the feature disabled entirely — the third state in the diagram above is the one nobody tests and the one a real proportion of readers get.


FAQ

What happens in a browser with no scroll-timeline support? The animation-timeline declaration is dropped as unknown, but the animation shorthand is still valid and runs on the document timeline. With no duration declared that resolves to zero seconds, so the element jumps instantly to its final keyframe.

Can I feature-detect scroll-driven animations without @supports? You can test CSS.supports('animation-timeline', 'scroll()') in script, but there is rarely a reason to. @supports is evaluated at style time with no scripting dependency, so the enhancement applies before first paint rather than after hydration.

Is prefers-reduced-motion enough on its own, or do I need a site setting too? The media query is the baseline obligation and covers users who have configured the operating system. A visible in-page toggle is a reasonable addition for shared devices and for users who never found the OS setting, but it does not replace the query.

Should reduced motion disable a scroll-driven animation entirely? Only the part that moves things. Remove translation, scaling, rotation, and parallax, since scroll-coupled positional change is the vestibular trigger. An opacity or colour change tied to scroll is usually acceptable and often carries information worth keeping.


Related articles

More pages in the same section.