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.
In this guide
- Building a Motion Intensity Toggle with CSS Custom Properties — a user-facing intensity control that writes
--motion-scalevalues, persists the choice, and layers correctly over the OS preference
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
Frequently Asked Questions
Why does the scale need @property at all?
Because an unregistered custom property is an untyped token as far as the cascade is concerned. calc(400ms * var(--motion-scale)) looks like arithmetic, but without registration the browser has no type information for the substituted value, so the declaration is invalid at computed-value time and is dropped entirely. The animation then keeps its authored duration and nothing in the console mentions it.
Registration with syntax: '<number>' makes the value a real number, at which point the multiplication resolves and the property also becomes interpolable — so a change to the scale can itself be animated rather than snapping.
What happens at scale zero?
Durations become zero and distances collapse, which is the intended reduced state: the end state is applied with no transit. This is worth testing explicitly, because a few patterns behave oddly at exactly zero — a keyframe sequence that relies on an intermediate position, or an easing curve applied to a zero-length range. Where that happens, clamp the scale to a small non-zero floor for that specific effect rather than special-casing zero across the whole system.
Should the scale apply to view transitions too?
Yes, and it is one of the clearest wins. A transition duration expressed as calc(var(--transition-base) * var(--motion-scale)) on the generated pseudo-elements gives the same control over page changes that the scale gives over scroll effects, with no additional machinery. Displacement inside the transition scales the same way if the group animation is written in terms of a scaled distance rather than the browser’s default.
How does the scale interact with the system preference?
The system preference sets the starting value; the control overrides it once used. Implement that by defaulting --motion-scale to 1, lowering it inside a prefers-reduced-motion: reduce block, and having the stored user choice — when one exists — set it at a higher-priority origin. The order matters: a stored choice must outrank the media query, or the control will appear not to work for exactly the users most likely to reach for it.
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.
Can the scale itself be animated when the user moves the control?
Yes, and it is worth doing. Because the property is registered as a number it interpolates, so a transition on --motion-scale lets the whole composition ease from one intensity to another instead of snapping between presets. Two hundred milliseconds is enough to make the control feel like it is adjusting something continuous.
There is an irony worth noticing: animating the motion control is itself motion. Keep that transition short, keep it to the scale value rather than to any displacement, and skip it entirely when the resolved scale is zero — a user who has just asked for no motion should not then watch the page ease its way into stillness.
What should the control look like when the system preference is already set?
It should already be at the reduced position, and it should say so. A slider sitting at 0.3 with no explanation looks like an arbitrary default; the same slider with a short line noting that the value came from the operating system tells the user their existing preference was honoured and that they can override it here. That single sentence removes most of the confusion an in-page motion control otherwise creates.
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