Motion Scaling & User Preferences in CSS
Binary on/off motion control misses a large segment of users who need reduced — not eliminated — animation. A user with vestibular hypersensitivity may tolerate a gentle 50 ms fade while finding a 400 ms parallax sweep disorienting. Motion scaling maps the OS preference signal to a continuous CSS factor, so you can slow parallax, shorten view transitions, and dampen easing curves proportionally. This approach extends the detection patterns covered in Implementing prefers-reduced-motion in scroll-driven animations with a --motion-scale custom property that is compatible with both scroll() and view() timeline APIs.
Pages in This Section
- How to Respect prefers-reduced-motion in CSS — step-by-step override patterns for scroll timelines, view transitions, and keyframes
Syntax Reference
The core of the architecture is a single registered custom property and a media-query override.
/* Register the property so calc() treats it as a <number>, not a string */
@property --motion-scale {
syntax: '<number>';
initial-value: 1;
inherits: true;
}
:root {
--motion-scale: 1;
--motion-base-duration: 400ms;
}
/* Full reduction: collapse all calc()-scaled durations to 0ms */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-scale: 0;
}
}
Registering --motion-scale via @property with syntax: '<number>' is mandatory. Without registration, the browser treats the value as an opaque string and calc(400ms * var(--motion-scale)) resolves to calc(400ms * 1) — it does not interpolate proportionally. inherits: true means descendant elements automatically receive the updated value without any additional selectors.
Setting --motion-scale: 0 collapses calc(var(--motion-base-duration) * var(--motion-scale)) to 0ms. For a very short but non-zero fallback — useful when animationend events must still fire — use 0.05 instead.
Value semantics:
--motion-scale |
Effect |
|---|---|
1 |
Full motion (default) |
0.5 |
Half duration and displacement |
0.25 |
Quarter duration — present but brief |
0 |
No motion; calc() resolves to 0ms / 0px |
Minimal Working Example
<div class="card reveal-on-scroll">Hello world</div>
@property --motion-scale {
syntax: '<number>';
initial-value: 1;
inherits: true;
}
:root {
--motion-scale: 1;
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; }
}
.reveal-on-scroll {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(calc(2rem * var(--motion-scale)));
}
to {
opacity: 1;
transform: translateY(0);
}
}
At --motion-scale: 0 the element fades from transparent to opaque with no vertical displacement — the opacity change remains so the content is still visually introduced rather than blinking into existence.
Scoping the Effect: animation-range and Named Timelines
Scroll-driven animations are driven by scroll position, not wall-clock time, so animation-duration has a different meaning than in regular CSS animations. In the scroll() and view() timeline models, animation-duration acts as a reference for the internal progress calculation. Reducing it does not make the animation run faster in time — it changes how much of the scroll range the animation covers.
To scope the motion-reduction effect correctly:
/* animation-range: entry 0% entry 50% — runs during the entering phase */
.scroll-reveal {
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
/* For a named scroll timeline scoped to a specific container */
.carousel {
scroll-timeline: --carousel-timeline inline;
overflow-x: scroll;
}
.carousel-item {
animation: slide linear;
animation-timeline: --carousel-timeline;
}
When --motion-scale: 0 collapses keyframe displacements to zero, the animation still runs through its range but produces no visible movement — avoiding the need to reset animation-range in the media query. If you want to fully disable the timeline (not just eliminate displacement), reset animation-timeline: auto explicitly:
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
animation: none;
animation-timeline: auto; /* shorthand does not reset this */
opacity: 1;
transform: none;
}
}
For the parallax-in-keyframes approach, scaling the displacement is cleaner than resetting the timeline, because it preserves the progressive-enhancement structure within the @supports block.
Compositor-Safe Properties
Animate only the properties that remain on the compositor thread’s paint and composite pipeline to avoid main-thread recalculation during scroll. Scaling displacement values in --motion-scale-based keyframes does not change which thread handles the animation, but it does change the output value — verify with DevTools Layers panel.
| Property | Compositor thread | Notes |
|---|---|---|
transform |
Yes | Translate, scale, rotate — all compositor-safe |
opacity |
Yes | Fades run fully on the compositor |
filter (blur) |
Partial | blur() forces a raster step; use sparingly |
clip-path (simple shapes) |
Partial | Path complexity determines thread |
width / height |
No | Forces layout — avoid in keyframes |
margin / padding |
No | Forces layout — avoid in keyframes |
background-color |
No | Triggers paint — use opacity over a colour layer instead |
top / left |
No | Use translate() instead |
Add will-change: transform, opacity to elements that will animate, but remove it once the animation completes to release the GPU layer allocation:
.scroll-reveal {
will-change: transform, opacity;
}
.scroll-reveal.is-done {
will-change: auto;
}
Common Implementation Patterns
Pattern 1: Proportional Parallax Displacement
Scale the parallax displacement by --motion-scale rather than disabling the animation entirely:
@keyframes parallax-scaled {
from { transform: translateY(0); }
to { transform: translateY(calc(-15vh * var(--motion-scale))); }
}
.hero-parallax {
animation: parallax-scaled linear;
animation-timeline: scroll(root);
}
At --motion-scale: 0.25 the element moves 3.75vh instead of 15vh — present but far less disorienting. At 0 the element is stationary.
Pattern 2: Scaled View Transition Duration
Apply the scale factor to view transition pseudo-element durations so crossfades shorten proportionally:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: calc(0.4s * var(--motion-scale));
animation-timing-function: ease-out;
}
At --motion-scale: 0 this resolves to 0ms, skipping the crossfade entirely. At 0.25 it runs at 100 ms — perceptible but brief. The SPA page-swap animations pattern depends on these pseudo-elements, so a single --motion-scale override cascades to all view transitions across the application.
Pattern 3: Scroll Progress Indicator Scaling
For a reading progress bar built with scroll-timeline, the animation itself is informational rather than decorative — do not disable it entirely. Instead, scale only the visual weight:
.reading-progress {
--bar-height: calc(4px * (1 + (1 - var(--motion-scale))));
height: var(--bar-height);
animation: progress linear;
animation-timeline: scroll(root);
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}
At full scale the bar is 4 px. At --motion-scale: 0 it becomes 8 px — more visually static, easier to read as a passive indicator.
Pattern 4: Sticky Header Easing Reduction
Reduced easing curves feel less bouncy for users sensitive to oscillating motion:
:root {
--motion-scale: 1;
--nav-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* full motion */
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-scale: 0;
--nav-easing: linear; /* no spring when motion is off */
}
}
.sticky-header {
transition:
transform calc(200ms * var(--motion-scale)) var(--nav-easing),
opacity calc(150ms * var(--motion-scale)) linear;
}
The sticky header and navigation transition patterns should always apply the easing override alongside the duration override.
Scale Visualization
The diagram below shows how --motion-scale maps to displacement and duration at three common values.
Browser Support and @supports Guard
@property registration (required for calc() to treat --motion-scale as a number) is supported in Chrome 85+, Edge 85+, and Safari 16.4+. Firefox 128+ supports @property as well, so coverage is now broad but not universal.
Guard the enhanced behaviour with @supports:
/* Baseline: always visible, no animation */
.scroll-reveal {
opacity: 1;
transform: none;
}
/* Enhancement: only activate if both APIs are available */
@supports (animation-timeline: scroll()) and (syntax: '<number>') {
.scroll-reveal {
opacity: 0;
transform: translateY(calc(2rem * var(--motion-scale)));
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
}
The browser support and progressive enhancement patterns guide covers @supports fallback recipes for older Safari and Firefox in detail.
For Firefox versions below 128, @property is unavailable and --motion-scale behaves as an unregistered custom property — calc() receives a string operand and the expression is invalid. In that case the element uses its declared base value unchanged. If you need consistent reduced-motion behaviour in older Firefox, pair the CSS approach with a small JavaScript fallback:
// Fallback for browsers without @property support
const supportsAtProperty = CSS.supports('syntax', "'<number>'");
if (!supportsAtProperty) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const apply = (mq) => {
document.documentElement.style.setProperty(
'--motion-scale',
mq.matches ? '0' : '1'
);
};
apply(reducedMotion);
reducedMotion.addEventListener('change', apply);
}
Gotchas and Failure Modes
-
animationshorthand does not resetanimation-timeline. Settinganimation: nonein a reduced-motion override removes keyframes and duration but leaves the scroll-driven timeline attached. The element continues to respond to scroll position. Always pairanimation: nonewithanimation-timeline: auto. -
calc()string concatenation without@property. Without the@propertyregistration,calc(400ms * var(--motion-scale))is not a valid numeric expression — the browser cannot multiply a<time>by an untyped custom property. The result is theinitial-valuefallback or an invalid declaration, not a scaled duration. -
--motion-scale: 0does not disableview()timelines. Setting a displacement tocalc(2rem * 0)gives0pxdisplacement, but the animation still tracks the element’s intersection with the viewport. If you want to remove the timeline entirely (e.g. to avoid staleanimation-fill-mode: bothstates), explicitly resetanimation-timeline: autoandanimation-fill-mode: none. -
Specificity conflicts with the nuclear reset pattern. Some codebases use
*, *::before, *::after { animation-duration: 0.01ms !important }inside the reduced-motion media query. This can override the--motion-scalearchitecture’s intentional0.25intermediate values. Choose one approach: the!importantnuclear reset, or the--motion-scaleproportional system — not both. -
will-changepersistence bloats GPU memory. Applyingwill-change: transformunconditionally to long lists of scroll-animated elements creates a persistent compositing layer for each one, even after the animation completes. Setwill-change: autoonce the element’s animation range has passed. -
Scaled easing vs. scaled duration interact unexpectedly. Halving duration while keeping a
cubic-bezierwith overshoot produces a noticeably snappier spring that can feel more aggressive despite a reduced displacement. For reduced-motion contexts, replace overshoot easings withease-outorlinearalongside the duration reduction.
Performance Checklist
@propertydeclaration precedes all uses of--motion-scalein the stylesheet (or in a<style>block before the first component that uses it)- All scroll-driven keyframes animate only
transformandopacity— no layout or paint properties will-changeis removed after animation completes via JavaScript class toggle oranimationendlisteneranimation-timeline: autois explicit in every reduced-motion override block — not implied byanimation: none@supports (animation-timeline: scroll())guards hide-then-reveal patterns so content is always visible without animation support- DevTools Performance panel confirms scroll-driven elements remain on the compositor track even at
--motion-scale: 0.25 document.getAnimations().filter(a => a.playState !== 'idle')returns an empty array whenprefers-reduced-motion: reduceis emulated and--motion-scale: 0is in effect- View transition pseudo-elements
::view-transition-oldand::view-transition-newhave scaledanimation-durationvalues verified in DevTools Animations panel
Design Token Integration
Map --motion-scale to your design system’s duration tokens so all components inherit the scale without changes to component code:
:root {
/* Source-of-truth durations */
--duration-fast: 150ms;
--duration-medium: 300ms;
--duration-slow: 500ms;
--motion-scale: 1;
/* Scaled aliases — reference these in components */
--anim-fast: calc(var(--duration-fast) * var(--motion-scale));
--anim-medium: calc(var(--duration-medium) * var(--motion-scale));
--anim-slow: calc(var(--duration-slow) * var(--motion-scale));
}
@media (prefers-reduced-motion: reduce) {
:root { --motion-scale: 0; }
}
/* Component using only the alias */
.dropdown {
transition: opacity var(--anim-fast) ease-out,
transform var(--anim-fast) ease-out;
}
Components reference --anim-fast, --anim-medium, and --anim-slow. A single --motion-scale change at :root propagates to all of them. Design tools that expose CSS custom properties (Figma Tokens, Style Dictionary) can export --duration-* values while the CSS layer applies the scale factor at runtime.
Up: Accessibility & Inclusive Motion Standards
Related:
- Implementing prefers-reduced-motion in Scroll-Driven Animations — the media query overrides and JS guards that feed into this scaling architecture
- The Rendering Pipeline for Scroll Animations — compositor thread behavior that determines which properties can be safely scaled
- Browser Support & Progressive Enhancement —
@supportsguard patterns and polyfill strategies for@propertyandanimation-timeline - Parallax Effects with Pure CSS — parallax displacement patterns that apply
--motion-scaleto limit displacement