Building a Motion Intensity Toggle with CSS Custom Properties

The OS-level motion preference is binary, but motion sensitivity is not. An in-page intensity toggle — full, reduced, none — lets users pick the level that works for them without digging into system settings, and a single --motion-scale custom property lets the entire animation layer honour that choice with no per-component code. This page builds the complete control: the registered property, scaled scroll-driven keyframes, a persisted three-state radio group, and OS-preference initialisation. It is the hands-on companion to the --motion-scale architecture in the parent guide on motion scaling and user preferences, within the Accessibility & Inclusive Motion Standards section.

When to use this approach

Build an in-page toggle when the OS signal alone is too coarse for your interface:

  • Motion-heavy marketing or storytelling pages — parallax, pinned scenes, and long scroll-driven sequences are exactly the effects users most want to dial down rather than lose entirely.
  • Users on shared or locked-down machines — kiosk, classroom, and corporate devices often block access to OS accessibility settings; an in-page control is the only preference they can express.
  • A “reduced but not zero” middle state — prefers-reduced-motion cannot express some motion; a 0.4 scale keeps spatial orientation cues while removing the sweep that triggers vestibular symptoms.
  • Interactive scroll effects covered by WCAG 2.2 animation compliance criteria — SC 2.3.3 asks for a way to disable non-essential interaction-triggered motion, and an explicit toggle is the most testable way to provide one.

Skip the toggle when your site has only trivial motion (a fade here, a 150 ms transition there): the standard media-query override is enough, and an extra control would be UI noise. The toggle supplements prefers-reduced-motion — it must never be the only way to reduce motion, and it must never override an OS “reduce” signal with more motion by default.


Motion intensity toggle states mapped to --motion-scale Three toggle states — full, reduced, and none — set the data-motion attribute on the document root. Full maps to a motion scale of 1, reduced to 0.4, and none to 0. All three feed one registered custom property, which multiplies keyframe displacement and duration, so a 40 pixel translation becomes 40, 16, or 0 pixels respectively. The initial state comes from localStorage or, if empty, from prefers-reduced-motion. localStorage 'motion-pref' prefers-reduced-motion Toggle: full | reduced | none sets html[data-motion] full --motion-scale: 1 reduced --motion-scale: 0.4 none --motion-scale: 0 translateY(calc(40px * var(--motion-scale))) 40px → 16px → 0px

Implementation

Step 1: Register --motion-scale and map it to toggle states

Register the property with @property so calc() can multiply lengths and durations by it, then bind each value to a data-motion attribute on the root element:

@property --motion-scale {
  syntax: '<number>';
  initial-value: 1;
  inherits: true;
}

:root { --motion-scale: 1; }                     /* full (default) */
:root[data-motion='reduced'] { --motion-scale: 0.4; }
:root[data-motion='none']    { --motion-scale: 0; }

/* OS preference wins until the user makes an explicit in-page choice */
@media (prefers-reduced-motion: reduce) {
  :root:not([data-motion]) { --motion-scale: 0; }
}

The :root:not([data-motion]) selector is the contract of the whole component: with no stored choice, the OS preference decides; once JS stamps data-motion, the explicit choice takes over. inherits: true means every descendant — including ::before/::after pseudo-elements and view-transition pseudos — reads the same value with no extra selectors.

Step 2: Multiply displacement and duration in your keyframes

Every scroll-driven effect must route its magnitude through the property. For keyframes attached to a view() or scroll() timeline, scale the displacement — scroll-linked progress has no wall-clock duration to shorten:

.reveal-card {
  animation: rise-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}

@keyframes rise-in {
  from {
    opacity: 0;
    /* 40px at full, 16px at reduced, 0px at none */
    transform: translateY(calc(40px * var(--motion-scale)));
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Time-based transitions scale their duration instead */
.menu-panel {
  transition: translate calc(250ms * var(--motion-scale)) ease-out;
}

At --motion-scale: 0 the card still fades in — opacity is deliberately not multiplied, so content never blinks into place invisibly — but travels zero pixels. The none state should additionally detach timelines so nothing tracks scroll position at all:

:root[data-motion='none'] .reveal-card {
  animation: none;
  animation-timeline: auto; /* the shorthand does not reset this */
  opacity: 1;
  transform: none;
}

Step 3: Mark up the three-state control

Native radio buttons give you keyboard arrow-key behaviour, a single tab stop, and mutually exclusive state for free — no ARIA repair work needed:

<fieldset class="motion-toggle">
  <legend>Animation intensity</legend>

  <label>
    <input type="radio" name="motion" value="full">
    Full
  </label>
  <label>
    <input type="radio" name="motion" value="reduced">
    Reduced
  </label>
  <label>
    <input type="radio" name="motion" value="none">
    None
  </label>

  <p class="motion-toggle__status" role="status"></p>
</fieldset>

Keep the toggle reachable from every page (footer or preferences panel) and outside any scroll-animated container — a control that slides around while the user tries to reduce sliding is a self-defeating design.

Step 4: Persist to localStorage and initialise from the OS preference

The controller does four things: restore a stored choice, otherwise derive the default from prefers-reduced-motion, write changes back, and keep following the OS signal until the user overrides it. The matchMedia detection patterns do the OS half of the work:

const STORAGE_KEY = 'motion-pref';
const VALID = ['full', 'reduced', 'none'];
const osQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

function currentPreference() {
  const stored = localStorage.getItem(STORAGE_KEY);
  if (VALID.includes(stored)) return { value: stored, explicit: true };
  // No stored choice: derive the default from the OS
  return { value: osQuery.matches ? 'none' : 'full', explicit: false };
}

function apply(value, { persist }) {
  document.documentElement.dataset.motion = value;
  if (persist) localStorage.setItem(STORAGE_KEY, value);
  // Reflect state into the radio group
  const input = document.querySelector(`input[name="motion"][value="${value}"]`);
  if (input) input.checked = true;
}

// Initialise
const pref = currentPreference();
apply(pref.value, { persist: false });

// User changes win and persist
document.querySelectorAll('input[name="motion"]').forEach((input) => {
  input.addEventListener('change', () => {
    apply(input.value, { persist: true });
    document.querySelector('.motion-toggle__status').textContent =
      `Animation intensity set to ${input.value}.`;
  });
});

// Follow live OS flips only while the user has not chosen explicitly
osQuery.addEventListener('change', (event) => {
  if (!localStorage.getItem(STORAGE_KEY)) {
    apply(event.matches ? 'none' : 'full', { persist: false });
  }
});

Run the initialisation as an inline <script> before the stylesheet-dependent content paints (or at least before first scroll) so a stored none choice never flashes a frame of full-intensity parallax. Note that initialisation does not persist — only a deliberate click writes to storage, preserving the “explicit choice” semantics.

Step 5: Make the toggle itself accessible

The native fieldset/legend/radio structure covers roles and grouping; three additions complete it:

  • Announce the change. The role="status" element in Step 3 is an implicit polite live region; updating its text after each change confirms the new state to screen-reader users without stealing focus. Never announce on initialisation — only on user action.
  • Do not rely on colour or motion to show the active state. The checked radio must be visibly distinct via the control itself (and a visible focus ring); an intensity control whose selected state is shown by an animated slider defeats its own purpose at none.
  • Label for the setting, not the widget. “Animation intensity” as the legend reads correctly in context menus and speech interfaces; per-option labels stay one word so arrow-key cycling is fast to audit against SC 2.2.2 and its neighbours.

Verification

  1. Load the page with no stored key and OS motion reduction off: document.documentElement.dataset.motion should be full and getComputedStyle(document.documentElement).getPropertyValue('--motion-scale') should be 1.
  2. Enable OS motion reduction (or DevTools → Rendering → emulate prefers-reduced-motion: reduce) and reload: the attribute should read none with scale 0, and localStorage.getItem('motion-pref') must still be null — initialisation must not persist.
  3. Select Reduced: the computed scale flips to 0.4 live, a scroll-driven card’s from transform computes to translateY(16px), and the status region announces the change (verify in the accessibility tree or with a screen reader).
  4. Reload: the choice survives via localStorage and beats the emulated OS preference.
  5. Select None, then run document.getAnimations().filter(a => a.playState === 'running') in the console after scrolling — scroll-linked entries must be gone, confirming the animation-timeline: auto reset in Step 2 took effect.
  6. Tab to the group and cycle options with arrow keys only — full keyboard operability, one tab stop.

Because OS toggles and engines disagree in the details, repeat check 2 on real devices per the guide to testing prefers-reduced-motion across operating systems and browsers before trusting the initialisation path.

Edge cases and gotchas

Unregistered property = broken calc(). Without the @property registration, calc(40px * var(--motion-scale)) multiplies a length by an untyped token and the declaration is invalid at computed-value time — the element typically loses its transform entirely rather than falling back gracefully. The registration is load-bearing, not an optimisation.

localStorage can throw. Private-browsing modes and blocked third-party contexts throw on setItem. Wrap storage access in try/catch and fall back to the OS-derived default; the toggle should still work for the session even when persistence fails.

Don’t let full override the OS signal silently. A user with OS reduction enabled who explicitly picks Full has made a legitimate choice — but that choice must be theirs. Never default data-motion to full when prefers-reduced-motion: reduce is active; the :root:not([data-motion]) guard in Step 1 plus the non-persisting initialisation in Step 4 enforce this.

The nuclear reset fights the scale. A global * { animation-duration: 0.01ms !important } reduced-motion rule (common in resets) clobbers the intentional 0.4 middle state. Pick one system: if you adopt --motion-scale, the global block should set --motion-scale: 0 instead of hammering durations.

Multiple tabs drift. A choice made in one tab does not update another tab’s data-motion until reload. Listen for the storage event and re-apply if same-session consistency across tabs matters.

Cached transforms at state flips. Elements mid-way through a view() timeline when the user picks None keep their last composited transform until the animation: none override lands. Apply the Step 2 none block to every scroll-animated selector, not just the showcase ones, or audit with the DevTools Animations drawer.

Browser-specific notes

Chrome / Edge (Chromium). Everything on this page is live in release: @property since Chrome 85, animation-timeline: scroll()/view() since Chrome 115 / Edge 115. This is the engine to develop the toggle against, and the only one (as of mid-2026) where step 5’s scroll-linked getAnimations() check exercises real timelines.

Safari (WebKit). @property has been supported since Safari 16.4, so the --motion-scale multiplication works for time-based transitions and regular keyframe animations today. Scroll-driven animations are in active development — present in Safari Technology Preview and the Safari 26 beta line — so animation-timeline: view() declarations are currently inert in release Safari and the toggle simply scales your time-based motion.

Firefox (Gecko). @property is supported from Firefox 128, but animation-timeline remains behind a pref in Nightly builds. As with Safari, the toggle degrades gracefully: scroll-linked declarations are ignored, while scaled transition durations and standard keyframe animations respond to all three states. Keep the scroll-timeline CSS inside an @supports (animation-timeline: scroll()) block so the from keyframe’s hidden state can never strand content in a non-supporting engine.


Up: Motion Scaling & User Preferences