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

Pages in This Section

Syntax Reference

The core of the architecture is a single registered custom property and a media-query override.

Anatomy of the @property registration The @property at-rule for --motion-scale is broken into its three descriptors. The syntax descriptor makes the value a real number so calc can interpolate it, initial-value supplies the fallback before any cascade wins, and inherits true pushes the value through the whole subtree without extra selectors. @property --motion-scale { syntax: '<number>'; initial-value: 1; inherits: true; } typed as a number, so calc() can multiply it full motion before any override applies reaches every descendant with no selector What the registration buys you calc(400ms * var(--motion-scale)) → 160ms at scale 0.4
/* 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.

--motion-scale values and their effect on duration and displacement Four columns representing --motion-scale at 0, 0.25, 0.5, and 1. Each column shows a bar proportional to the scale value, labeled with the resulting duration (0ms, 100ms, 200ms, 400ms) and displacement (0vh, 3.75vh, 7.5vh, 15vh). --motion-scale duration → calc(400ms × scale) displacement → calc(15vh × scale) 0ms 0vh 0 no motion 100ms 3.75vh 0.25 minimal 200ms 7.5vh 0.5 moderate 1 full motion 400ms 15vh

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

  1. animation shorthand does not reset animation-timeline. Setting animation: none in a reduced-motion override removes keyframes and duration but leaves the scroll-driven timeline attached. The element continues to respond to scroll position. Always pair animation: none with animation-timeline: auto.
Registered versus unregistered custom property Two paths for the same declaration. With @property registration the value is a typed number and calc resolves to a real duration. Without registration the value stays an untyped token, calc fails to resolve at computed-value time, and the declaration is dropped — the animation silently keeps its full duration. Registered with @property --motion-scale: 0.4 (typed number) calc(400ms * var(--motion-scale)) resolves to 160ms interpolates, animates, inherits Unregistered --motion-scale: 0.4 (opaque token) calc(400ms * var(--motion-scale)) invalid at computed value declaration dropped, full motion stays the same two lines of CSS, with and without the registration There is no console warning for the right-hand case — the property simply never appears in Computed.
  1. calc() string concatenation without @property. Without the @property registration, 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 the initial-value fallback or an invalid declaration, not a scaled duration.

  2. --motion-scale: 0 does not disable view() timelines. Setting a displacement to calc(2rem * 0) gives 0px displacement, but the animation still tracks the element’s intersection with the viewport. If you want to remove the timeline entirely (e.g. to avoid stale animation-fill-mode: both states), explicitly reset animation-timeline: auto and animation-fill-mode: none.

  3. 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-scale architecture’s intentional 0.25 intermediate values. Choose one approach: the !important nuclear reset, or the --motion-scale proportional system — not both.

  4. will-change persistence bloats GPU memory. Applying will-change: transform unconditionally to long lists of scroll-animated elements creates a persistent compositing layer for each one, even after the animation completes. Set will-change: auto once the element’s animation range has passed.

  5. Scaled easing vs. scaled duration interact unexpectedly. Halving duration while keeping a cubic-bezier with overshoot produces a noticeably snappier spring that can feel more aggressive despite a reduced displacement. For reduced-motion contexts, replace overshoot easings with ease-out or linear alongside the duration reduction.

Performance Checklist

  • @property declaration precedes all uses of --motion-scale in the stylesheet (or in a <style> block before the first component that uses it)
  • All scroll-driven keyframes animate only transform and opacity — no layout or paint properties
  • will-change is removed after animation completes via JavaScript class toggle or animationend listener
  • animation-timeline: auto is explicit in every reduced-motion override block — not implied by animation: 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 when prefers-reduced-motion: reduce is emulated and --motion-scale: 0 is in effect
  • View transition pseudo-elements ::view-transition-old and ::view-transition-new have scaled animation-duration values 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: