Baseline-First CSS for Scroll Animations

The most damaging failure in scroll-driven work is not a broken animation — it is a section that renders nothing, because the animation’s initial state was written as the component’s default and the engine never ran the animation that would have undone it. The fix is not a technique but an order of authoring, and written in that order the failure becomes impossible rather than merely unlikely. This guide is that order. It sits under browser support and progressive enhancement.

When to use this approach

Always, for anything animated. It costs nothing and it is the difference between a cosmetic degradation and a content loss. It matters most when:

  • The animation hides content in its initial state — opacity zero, a clip, a scale of zero, a translate that pushes an element off-screen.
  • The component is shared. A design-system component authored animation-first exports the failure to every consumer.
  • Support is uneven. Where an engine you support lacks the feature, the baseline is what most of your readers actually see.
What writing it backwards produces A component authored animation-first has its initial state — opacity zero, translated, clipped — as the default. An engine without timeline support never runs the animation that would undo it, so the content is invisible. This is the single most damaging failure in progressive enhancement for animation. Animation-first opacity: 0 (the default) no timeline support — stays invisible Baseline-first opacity: 1 (the default) no timeline support — content visible A missing animation is a cosmetic loss. Missing content is not, and the two differ by which line you wrote first.

Implementation

1. Write the finished component with no animation at all

.reveal {
  opacity: 1;
  translate: none;
  clip-path: none;
}

Load it, look at it, and confirm it is something you would ship. This is what unsupported engines and reduced-motion readers receive, and it is three of the four states the component has to be correct in.

Four states, all correct Support and preference are independent, producing four combinations. Supported with motion gives the designed experience. Supported with reduced motion gives the static version. Unsupported with either gives the static version too. Writing the static version first makes three of the four correct by construction. supported + motion the designed experience supported + reduce the step-one static version unsupported + motion the step-one static version unsupported + reduce the step-one static version Three of the four are the same page — which is why getting that page right first is most of the work.

2. Add the animation inside a feature query

@supports (animation-timeline: view()) {
  .reveal {
    opacity: 0;
    translate: 0 16px;
    animation: rise linear both;
    animation-timeline: view(block);
    animation-range: entry 20% entry 80%;
  }

  @keyframes rise {
    to { opacity: 1; translate: 0 0; }
  }
}

Everything that hides the element goes inside the guard, including the initial state. That is the whole discipline: an engine that cannot run the animation never sees the state the animation was going to undo.

3. Test the function you use, not the property

/* meaningless — the initial value parses almost everywhere */
@supports (animation-timeline: auto) { }

/* meaningful */
@supports (animation-timeline: view()) { }

4. Put the reduced-motion reset last

Write it in this order Three passes over the same component. First the complete static version, with everything visible and no animation. Then the animation added inside a feature query. Then the reduced-motion reset. Written in this order the broken intermediate states are impossible to produce. 1. The static component everything visible, correctly laid out, no animation anywhere — ship-quality on its own 2. The animation, inside a feature query initial state, keyframes, timeline and range all inside the guard 3. The reduced-motion reset, last restores the step-one state, and wins because it comes after
@media (prefers-reduced-motion: reduce) {
  .reveal {
    animation: none;
    opacity: 1;
    translate: none;
  }
}

Placed after the feature query — or in a later cascade layer — it wins in every combination. Placed before it, the page honours the preference in engines without support and ignores it in engines with support, which is precisely backwards.

5. Keep the reset symmetrical with the initial state

Every property the guard sets to a hiding value must be restored. A reset that clears animation but leaves opacity: 0 is the same failure as writing the animation first, arriving by a different route.

Verification

Comment out the @supports block and reload. The component must look complete. If it does not, the baseline is wrong and no amount of guarding will save it.

Emulate reduced motion and confirm the same complete state. This catches an asymmetric reset, which is the most common defect once the ordering is right.

Test in an engine without the feature. Where none is available, the commented-out test above is a good proxy — but a real engine also exercises whatever JavaScript path accompanies the CSS.

Finally, disable JavaScript and reload. A scroll-driven component should be entirely unaffected, and anything that breaks is a dependency you did not know you had.

Edge cases and gotchas

A baseline that is technically visible but visually wrong. An element left at a translated position is visible and misplaced. The baseline needs to be the finished state, not merely a non-hidden one.

Guards around the wrong scope. Wrapping the whole component in @supports puts its layout inside the guard too, which is the original failure with more steps.

Keyframes outside the guard. Harmless — an unused @keyframes block costs nothing — but keeping them inside makes the enhancement self-contained and easier to delete later.

Cascade layers changing the ordering. If the site uses layers, the reduced-motion block must be in a layer that comes after the one holding the enhancement, and unlayered rules beat all of them.

Component frameworks with scoped styles. Scoping does not change cascade order between the guard and the reset; it does make it easier to accidentally place them in different files where the order is not obvious.

Browser-specific notes

@supports is uniformly supported and evaluates identically everywhere, so the technique itself has no engine variation.

Firefox is the most useful engine to test this in, because same-document view transitions are behind a flag in current stable builds — which means the unsupported branch is exercised by default rather than only in a hypothetical old browser. A component that looks right in Firefox has a baseline that works.

Safari’s timeline support is recent enough that older stable versions in the field still take the unsupported path, which makes the baseline the experience a real share of visitors receive rather than a theoretical one.

Chromium supports the whole surface, which is exactly why a component developed only there can ship with an initial state that is never undone anywhere else and nobody notices.

Frequently Asked Questions

Does this mean writing every component twice?

No — the baseline is not a second version, it is the component. The enhancement adds an initial state and an animation on top of it, which is usually five or six extra declarations. What the order changes is which of the two you write first, and therefore which one is complete.

Is a feature query needed if support is broad?

The query costs a few bytes and never needs revisiting, so leaving it in is free. What is worth revisiting as support broadens is any fallback implementation — a polyfill or a JavaScript replacement — since those carry maintenance weight. Keep the guard, retire the fallback.

What about animations that do not hide anything?

A hover lift or a colour transition has no hiding initial state, so the failure this guide prevents cannot occur. Guarding them is still tidy but the stakes are much lower — this discipline matters in proportion to how much of the content the initial state conceals.

How does this interact with server-side rendering?

Well, and it is another argument for it. A baseline-first component renders correctly in the initial HTML before any script or hydration runs, so there is no flash of hidden content while the page comes alive. An animation-first component shows nothing until hydration completes.

How do I retrofit this into an existing animation-first component?

Invert it in three edits. Move the hiding declarations — the initial opacity, translate or clip — from the base rule into the @supports block. Confirm the base rule now renders the finished state. Then check the reduced-motion reset restores exactly the same properties the guard sets.

The change is small and the verification is the part worth doing carefully: comment out the guard and look at the component, because that is the state you have just created for every unsupported engine and it is the one nobody has seen.

Does this apply to view transitions as well?

Yes, with a different shape. A view transition’s baseline is the navigation working without any animation, which it already does — the DOM update is independent of the transition. What needs the same discipline is any styling applied in anticipation of a transition, and any focus or announcement work, which must happen on the unsupported path too rather than only inside the transition branch.

Is there a quick way to spot animation-first components in a codebase?

Search for opacity: 0 and visibility: hidden outside of @supports and @media blocks. Most hits are legitimate — hidden panels, decorative overlays — but any that belong to an animated component are exactly the failure this guide describes, and the list is usually short enough to check by hand.


Up: Browser Support & Progressive Enhancement