Skip to content

Reference · motion

Fallbacks: what a browser without scroll-driven animations renders

motion/references/fallbacks.md179 linesupdated 16 Sept 2026

void's home page shipped effectively blank in Firefox: header, one chip and a decorative shape rendered; the headline, sub-copy and live terminal did not, because their base CSS sat at opacity: 0 and only the scroll-driven animation ever undid it. void audit scored the page smooth 99–100 because every check ran in Chromium, which supports the feature — the bug was invisible to the tool that exists to catch exactly this. This reference is the rule that closes it, and the check (smooth/scroll-timeline-no-fallback, lint/scroll-timeline-no-fallback) that enforces it.

1. The hard rule

Keyframes own the from-state. A base rule — anything outside @keyframes — is always the finished, visible state.

@supports (animation-timeline: view()) (or scroll(), or a bare view-timeline/animation-range) has no fallback path. A browser that doesn't implement the feature evaluates the condition as false and drops the entire block, keyframes included — not "degrades to no animation", drops the CSS. If the hidden value (opacity: 0, a collapsed scale, an off-screen translate, visibility: hidden) lives in a rule outside that block, nothing in that browser ever undoes it. The content isn't degraded. It's gone, permanently, no matter how long the user waits or how far they scroll.

This is the same discipline reduced-motion-ignored/no-reduced-motion already enforce for prefers-reduced-motion (motion rule 10: "reduced motion = a finished static frame, never hidden content") — @supports just adds a second, more brutal way to lose an entire audience, because unlike prefers-reduced-motion there is no user setting to turn it off with, and unlike a slow network there is no amount of waiting that fixes it.

Incorrect — base rule hides it, only @supports undoes it

css
/* BUG: .hero-sub is opacity: 0 in every browser. Only one that supports
   animation-timeline ever runs the animation that sets it back to 1. */
.hero-sub {
  opacity: 0;
}
@supports (animation-timeline: view()) {
  .hero-sub {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}
@keyframes reveal {
  to { opacity: 1; }
}

Correct — the resting rule is already the finished state

css
/* .hero-sub is opacity: 1 (the default) everywhere. A supporting browser plays
   an entrance from 0; everyone else just has it, at rest, from first paint. */
@supports (animation-timeline: view()) {
  .hero-sub {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 60%;
  }
}
@keyframes reveal {
  from { opacity: 0; translate: 0 0.75rem; }
}

This is exactly @utility reveal in packages/tokens/css/base.css: opacity: 0 and the 12px rise live only inside @keyframes void-reveal, never in .reveal itself. Deleting the whole @supports block changes nothing about what's visible at rest — which is the property this rule tests for.

The same rule applies to transforms, masks and visibility:

css
/* wrong */
.card { transform: translateX(140%); }          /* off-canvas at rest, in every browser */
@supports (animation-timeline: view()) {
  .card { animation: slide-in linear both; animation-timeline: view(); }
}
@keyframes slide-in { to { transform: translateX(0); } }

/* right */
@supports (animation-timeline: view()) {
  .card { animation: slide-in linear both; animation-timeline: view(); }
}
@keyframes slide-in { from { transform: translateX(140%); } }
css
/* wrong: visibility:hidden outside @supports never gets undone without it */
.panel { visibility: hidden; }
@supports (animation-timeline: scroll()) {
  .panel { animation: unmask both; animation-timeline: scroll(root); }
}

/* right */
@supports (animation-timeline: scroll()) {
  .panel { animation: unmask both; animation-timeline: scroll(root); }
}
@keyframes unmask { from { visibility: hidden; } to { visibility: visible; } }

One more shape worth naming: animation: reveal both; animation-timeline: view(); declared as a plain, unconditional rule (not inside @supports at all) is just as broken — animation-timeline itself silently no-ops in a non-supporting browser (an unknown property value is dropped, not an error), the animation never gets a timeline, and depending on fill-mode the element can sit on its keyframe's from state forever. Always gate animation-timeline/view-timeline/animation-range behind @supports, and always let the block's own keyframe own the hidden value.

2. Browser support, verified (not from memory)

Checked directly in a real Firefox 151 (CSS.supports('animation-timeline', 'view()')false; likewise scroll(), view-timeline-name, animation-range) and in Chromium 149 (CSS.supportstrue for all four). Don't trust a remembered support table for a fast-moving CSS feature — re-check with CSS.supports() in the actual engine before relying on it.

Feature Chrome/Edge Safari Firefox
animation-timeline: view() / scroll() 115 26 no support, through v151 (behind a flag only)
view-timeline, animation-range 115 26 no support, through v151

There is no Chromium launch flag that flips CSS.supports back to false for this — it shipped unconditionally in M115, not behind a --disable-blink-features=…-style runtime flag (every plausible flag name was tried and verified against CSS.supports directly; none changed the result). That's why smooth/scroll-timeline-no-fallback reproduces the content of a non-supporting engine's cascade — stripping the matching @supports blocks from the CSS before the browser parses it — rather than trying to make Chromium stop supporting the feature.

Firefox is not a rounding error here: it's every Firefox version currently in use, ~2–3% of global traffic depending on the audience, and — same rendering path — every in-app WebView and embedded browser built on an older engine snapshot. Design as if this browser is real, because it is.

3. Designing the fallback on purpose

A scroll-driven reveal is an enhancement. The resting state — what a non-supporting browser (or a supporting one, before the animation has run) shows — is not "the page minus the motion", it's a design you're responsible for, same as a loading state or an empty state.

  • Everything is there, in its final position, from first paint. No staged reveal, no partial layout. If the page would look "finished" a beat after the animation completes, that's what everyone without timeline support gets immediately — which reads as a faster site, not a broken one.
  • Don't rely on scroll to ever restore visibility. A non-supporting browser never runs the @supports block, at any scroll position. If the resting state is wrong, it is wrong forever, not "wrong until you scroll".
  • Keep any color/border/shadow embellishment that isn't the motion itself in the base rule. Only the literal animated property (opacity, transform, mask position) belongs in the keyframe.
  • Sanity-check the resting frame by itself: temporarily comment out the @supports block (or run the audit below) and look at the page. It should read as a deliberate, calmer version of the site — headline, copy and CTA all present — not a blank section with a chip floating in it.
  • This composes with prefers-reduced-motion. Both guards remove the animation and fall back to the same rule: the base state is the finished state. Get the base state right once and both paths are correct for free.

4. What the rule reports

Two checks, same shape, different cost:

  • lint/scroll-timeline-no-fallback (static, void lint) — parses the CSS for a selector that carries a hiding declaration (opacity: 0, visibility: hidden, a zero scale, a translate large enough to move content out of its box) as a base rule, where the only thing that ever undoes it (an animation/animation-name declaration, or a direct override back to a visible value) is inside an @supports (animation-timeline: …) block. No browser required — this is the fastest feedback for an agent, and it runs on every void lint pass.
  • smooth/scroll-timeline-no-fallback (runtime, void smooth / void audit) — loads the page twice: once normally, once with every matching @supports block stripped from the CSS before the browser parses it (and the ViewTimeline/ScrollTimeline globals removed, for the rare case of a JS-driven timeline) — two independent renders of the same URL, the second reproducing what a non-supporting engine computes. Text- and media-bearing elements visible in the first render and invisible in the second are reported with their selector, the text they hide, and which property is responsible (opacity, visibility, clip/transform, or a collapsed box). An element that's hidden in both renders (a closed menu, hidden, aria-hidden decoration) never flips, so it's never reported — only content that a scroll-timeline browser sees and a non-supporting one doesn't.

void audit also prints a one-line engine-coverage note whenever smooth runs: every browser-driven check executes in one engine (Chromium), and the scroll-timeline pass simulates a non-supporting engine within that same Chromium rather than actually running Firefox/WebKit. That's a meaningful difference from "we tested Firefox" worth saying out loud, not implying.

Fix in both cases is the same: move the hiding declaration out of the base rule and into the @keyframes referenced from inside the @supports block.