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-motioncannot express some motion; a0.4scale 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.
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
legendreads 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
- Load the page with no stored key and OS motion reduction off:
document.documentElement.dataset.motionshould befullandgetComputedStyle(document.documentElement).getPropertyValue('--motion-scale')should be1. - Enable OS motion reduction (or DevTools → Rendering → emulate
prefers-reduced-motion: reduce) and reload: the attribute should readnonewith scale0, andlocalStorage.getItem('motion-pref')must still benull— initialisation must not persist. - Select Reduced: the computed scale flips to
0.4live, a scroll-driven card’sfromtransform computes totranslateY(16px), and the status region announces the change (verify in the accessibility tree or with a screen reader). - Reload: the choice survives via localStorage and beats the emulated OS preference.
- Select None, then run
document.getAnimations().filter(a => a.playState === 'running')in the console after scrolling — scroll-linked entries must be gone, confirming theanimation-timeline: autoreset in Step 2 took effect. - 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.
Related
- Implementing prefers-reduced-motion in Scroll-Driven Animations — the binary media-query layer this toggle builds on top of
- How to Respect prefers-reduced-motion in CSS Animations — cascade-layer override placement that keeps toggle styles composable
- Vestibular-Safe Motion Design Patterns — how to choose the displacement values that the
reducedstate scales down to - Building Reduced-Motion Variants of View Transitions — applying the same scale factor to
::view-transition-*pseudo-elements - Parallax Effects with Pure CSS — the highest-impact place to route displacement through
--motion-scale