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

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.

--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.

  2. 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.

  3. --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.

  4. 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.

  5. 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.

  6. 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

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: