---
title: "Fallbacks: what a browser without scroll-driven animations renders (motion)"
description: "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…"
canonical: https://void-design.vercel.app/docs/motion/fallbacks
lastModified: 2026-09-16
---

# Fallbacks: what a browser without scroll-driven animations renders

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.supports` → `true` 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.
