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.
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.
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
@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.
Related
- Browser Support & Progressive Enhancement — the layered enhancement strategy this fits into
- @supports guard recipes for animation-timeline — the query forms in detail
- Fallback Strategies for Legacy Browsers — what to add when the baseline is not enough
- Implementing prefers-reduced-motion — the reset that comes last