@supports Guard Recipes for animation-timeline
Every animation-timeline declaration you ship needs a guard, because the property is fully supported in Chromium (Chrome 115 / Edge 115), behind a pref in Firefox, and still in active development in Safari (Technology Preview builds). The Browser Support & Progressive Enhancement guide covers the overall strategy; this page is the recipe card β the exact @supports conditions that work, the ones that lie to you, and how to combine them with prefers-reduced-motion and JavaScript mirrors, all grounded in the parsing model described in Core Animation Fundamentals & Browser Mechanics.
When to use this approach
@supports guards are the zero-dependency baseline for shipping scroll-driven animations. Pick the recipe that matches your situation:
- Value-function check
@supports (animation-timeline: scroll())(Recipe 1) β the default. Use it around every scroll-driven block unless you have a specific reason not to. It tests the exact value grammar you are about to use. - Property-only checks like
@supports (animation-timeline: auto)(Recipe 2) β avoid. They returntruein engines that parse the property but have not shipped the timeline machinery, which is precisely the Firefox trap discussed below. - Combined
@supports+@mediaguard (Recipe 4) β use when the animated state should exist only for users who accept motion. One nested block replaces two separate override chains. CSS.supports()mirror in JavaScript (Recipe 5) β use when a script needs to branch on the same condition: conditional polyfill loading, analytics on which path users get, or framework-level feature flags.- No guard at all β acceptable only when the unanimated state is identical to the animationβs end state and no keyframe ever hides content. In practice that discipline slips; write the guard.
If you are still deciding whether scroll-driven CSS or an observer-based approach fits your project at all, that trade-off is a separate question covered in CSS scroll-driven animations vs IntersectionObserver. This page assumes you have chosen the native API and need to deploy it safely.
Implementation
Step 1: Use the value-function check as your default guard
@supports tests whether the engine would accept a full property: value declaration. Testing animation-timeline: scroll() β the actual function you intend to use β is the strongest signal available in CSS:
/* Recipe 1 β the canonical guard pair */
/* Baseline: the animation's END state, unconditional */
.reveal {
opacity: 1;
transform: none;
}
/* Enhancement: only engines that accept the scroll() value enter here */
@supports (animation-timeline: scroll()) {
@keyframes reveal-in {
from { opacity: 0; transform: translateY(1.25rem); }
to { opacity: 1; transform: none; }
}
.reveal {
animation: reveal-in linear both;
animation-timeline: view(); /* view() ships alongside scroll() */
animation-range: entry 0% entry 60%;
}
}
Two details matter. First, the condition is evaluated once at parse time, so unsupported engines discard the whole block with zero runtime cost. Second, testing scroll() also covers view() in practice β no engine has shipped one timeline function without the other β but if you want maximum paranoia, test the value you use: @supports (animation-timeline: view()).
The explicit negative form is occasionally useful when the fallback needs different declarations rather than just the baseline:
/* Runs only where scroll timelines are absent */
@supports not (animation-timeline: scroll()) {
.reveal {
/* e.g. a subtle one-shot transition instead */
transition: opacity 0.3s ease;
}
}
Prefer the positive-guard-plus-unconditional-baseline structure when you can; not blocks create a second code path you must test separately.
Step 2: Avoid property-only checks β the Firefox partial-support trap
A property-only check tests whether the engine parses the property with some trivially valid value:
/* Recipe 2 β DO NOT use these as your gate */
@supports (animation-timeline: auto) { }
@supports (animation-timeline: none) { }
@supports (animation-timeline: --anything) { }
animation-timeline: auto is the initial value; an engine can accept it long before the scroll-driven machinery behind it works end to end. This is exactly the situation in Firefox: with layout.css.scroll-driven-animations.enabled flipped in Nightly, the property parses β so every one of these conditions, including the scroll() form, evaluates true β while the implementation behind it remains incomplete. Attach ranges, named timelines, or timeline-scope and you can get animations that parse but progress incorrectly or not at all.
The recipe that follows from this:
- Always test the value grammar you ship (
scroll()orview()), neverautoor a bare dashed-ident. - Treat
@supportsas necessary but not sufficient β it proves the parser accepts the syntax, not that the feature behaves. Behavioral verification (below) covers the gap. - Test your fallback in a stock Firefox profile with no prefs touched. A developer machine with Nightly and the pref enabled will happily take the enhanced path and hide the bug.
The same parse-versus-behavior gap applies to the View Transitions side of the platform, where @supports (view-transition-name: none) proves even less; the guard recipes for that API live at detecting View Transition support with @supports and JavaScript.
Step 3: Layer the enhancement β static end state first, scroll-driven on top
The single most important structural rule: the unconditional CSS must describe the finished state of the animation. The @supports block then adds the start state and the timeline. If you invert this β putting opacity: 0 in the base and revealing inside the guard β every non-supporting browser renders invisible content.
/* Layer 0 β every browser, every era: final resting state */
.card {
opacity: 1;
transform: none;
}
/* Layer 1 β engines with scroll timelines: add motion on top */
@supports (animation-timeline: scroll()) {
@keyframes card-enter {
from { opacity: 0; transform: translateY(2rem) scale(0.97); }
}
.card {
animation: card-enter linear both;
animation-timeline: view();
animation-range: entry 10% entry 70%;
}
}
Note the keyframes only declare from; the implicit to is the elementβs own computed style β which is the Layer 0 end state. This keeps the two layers from drifting apart: change the resting design once and both paths follow. It is the same layering discipline the reading progress bar implementation relies on, where the bar simply does not exist outside the guard.
Step 4: Combine with prefers-reduced-motion in one guard
Motion should only exist when the engine supports it and the user has not opted out. CSS Conditional Rules allow nesting @media inside @supports, giving you a single combined gate instead of two competing override chains:
/* Recipe 4 β one gate, two conditions */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.hero-figure {
animation: drift linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
}
}
Because the animated declarations only exist inside the double gate, there is nothing to undo: no animation: none !important, no animation-timeline: auto reset, no cascade-layer wrestling. Users with reduced motion get the Layer 0 end state directly. The nesting order is interchangeable (@media outside, @supports inside works identically); pick one convention and keep it consistent across the codebase.
The subtractive alternative β declaring motion broadly and cancelling it inside @media (prefers-reduced-motion: reduce) β still has its place when third-party CSS you do not control declares the animations. The full reset choreography for that case, including why animation-timeline survives the animation shorthand, is covered under implementing prefers-reduced-motion. For CSS you author yourself, the additive nested guard above is simpler and cannot leak.
Step 5: Mirror the checks in JavaScript with CSS.supports()
CSS.supports() evaluates the identical condition grammar at runtime, which lets scripts branch on the same truth your stylesheets use:
// Recipe 5 β keep the strings identical to your CSS guards
const hasScrollTimeline = CSS.supports('animation-timeline', 'scroll()');
const wantsMotion = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
).matches;
// Conditional polyfill: ship the extra bytes only where needed
if (!hasScrollTimeline && wantsMotion) {
import('./scroll-timeline-polyfill.js').then(({ init }) => init());
}
Three rules keep the mirror honest:
- Use the two-argument form β
CSS.supports('animation-timeline', 'scroll()'). The single-string form ('animation-timeline: scroll()') also works but is easier to typo into a silently-false condition, since malformed input returnsfalserather than throwing. - Test the same value as the CSS guard. If the stylesheet gates on
view()and the script testsscroll(), a future engine that ships them separately (or a polyfill that patches only one) desynchronizes your paths. - Remember the mirror inherits the trap.
CSS.supports()reports parser acceptance, exactly like@supportsβ Firefox Nightly with the pref returnstruehere too. Feature-detect, then verify behaviorally.
The polyfill branch deserves care: it must register before your guarded CSS can work in engines where the guard is false, and it interacts with cascade layers in non-obvious ways. The loading order, @layer placement, and WAAPI fallback details are documented in how to polyfill scroll-timeline for Safari. Note that a polyfilled engine still evaluates the CSS @supports guard as false β the polyfill patches the object model, not the parser β so polyfill-targeted styles must live outside the guard or be applied via the polyfillβs own mechanism.
Verification
Confirm both paths β enhanced and baseline β behave, not just parse:
- Console truth check. In each target browser, run
CSS.supports('animation-timeline', 'scroll()')and record the result. Chrome/Edge 115+ must returntrue; stock Firefox and current stable Safari returnfalse. - Baseline visibility audit. In a non-supporting browser (or with the guard temporarily renamed to
animation-timeline-xto force it false everywhere), load every page and confirm no content is invisible, clipped, or mid-animation-frozen. The Layer 0 end state must be complete on its own. - Behavioral check in Chrome. Elements panel β select an animated element β Computed β confirm
animation-timelineresolves toview()or a named timeline, then scroll and watch the Animations drawer scrub with scroll position rather than clock time. - Reduced-motion pass. DevTools β Rendering β emulate
prefers-reduced-motion: reduceβ reload. With the Recipe 4 nested gate,document.getAnimations()should return no scroll-attached animations at all. - Automated regression. Drive headless Chromium with the feature disabled to exercise the fallback path in CI:
// Playwright: force the baseline path in a supporting engine
const browser = await chromium.launch({
args: ['--disable-blink-features=CSSScrollDrivenAnimations']
});
const page = await browser.newPage();
await page.goto('/components/reveal-card/');
// The guard must now be false, and content must still be visible
const supported = await page.evaluate(
() => CSS.supports('animation-timeline', 'scroll()')
);
expect(supported).toBe(false);
await expect(page.locator('.card')).toHaveCSS('opacity', '1');
- Stock-profile Firefox check. Run the suite once in an untouched Firefox profile. If your teamβs Nightly-with-prefs setup was masking a trap, this is where it surfaces.
Edge cases and gotchas
@supports is parse-time and never re-evaluates. Unlike @media, a @supports blockβs truth is fixed for the lifetime of the stylesheet. Loading a polyfill later does not retroactively activate guarded CSS β another reason polyfill styles must live outside the guard.
The animation shorthand resets animation-timeline. Inside your guarded block, declare animation before animation-timeline, never after. The shorthand resets the timeline back to auto, silently converting your scroll-driven animation into a time-based one that plays instantly on load.
Draft-era spellings always evaluate false. @supports (scroll-timeline: root) tests a property spelling that shipped in no browser; guards written against early articles fail closed everywhere and quietly disable your enhancement in engines that do support it. The current longhand pair is scroll-timeline-name / scroll-timeline-axis.
Combining conditions with or loosens the gate. @supports (animation-timeline: scroll()) or (animation-timeline: view()) is redundant today and risky tomorrow: if an engine ever ships one function first, the or admits it into code that may use the other. Use and when you genuinely depend on both, or guard each usage by its own function.
Guard granularity. One page-wide @supports wrapper is easier to audit than twenty scattered ones, but it couples unrelated components to a single condition. A reasonable middle ground: one guard per component file, always testing the same condition string so a project-wide grep (animation-timeline: scroll()) finds every gate.
@supports inside @keyframes is invalid. Conditional rules cannot nest inside keyframe blocks. Guard the whole @keyframes declaration plus the properties that reference it together, as the recipes above do.
Browser-specific notes
| Engine | @supports (animation-timeline: scroll()) |
Actual behavior | Recipe implication |
|---|---|---|---|
| Chrome 115+ / Edge 115+ | true |
Full scroll-driven support, compositor-driven where properties allow | Enhanced path runs; verify ranges in the Animations drawer |
| Safari (current stable) | false |
Feature in active development; available in Technology Preview builds | Baseline end state renders; polyfill optional for motion parity |
| Safari Technology Preview | true (in-development builds) |
Implementation still stabilizing | Treat like a beta channel: test, but gate production expectations on stable |
| Firefox (stock) | false |
Pref layout.css.scroll-driven-animations.enabled off by default |
Baseline path; include guards for forward compatibility |
| Firefox Nightly + pref | true |
Property parses; implementation incomplete β the partial-support trap | Never validate fallbacks on this configuration |
| Legacy engines (pre-2023) | false |
No parsing, no support | Baseline path; @supports itself is safe in all of them |
Two engine-specific notes beyond the table. In Chromium, disabling the feature via --disable-blink-features=CSSScrollDrivenAnimations flips the guard to false cleanly, making it the most reliable way to test the baseline without a second browser. In Safari, because support is in active development, avoid pinning behavior expectations to Technology Preview details β keep the guard as the single decision point and let stable releases opt themselves in when they ship.
Related
- Fallback Strategies for Legacy Browsers β what to build on the other side of a false guard: observer replacements and FLIP shims
- Understanding the CSS Scroll-Timeline API β the
scroll()/view()grammar and range keywords the guards protect - How to Respect prefers-reduced-motion in CSS β the subtractive reset patterns for CSS you cannot restructure into nested guards
- Motion Preference Support Matrix: Browsers & Operating Systems β per-OS and per-engine behavior of the media features used in Recipe 4