Motion Sickness & Reduced Motion
Gameface runs inside the game engine, not the operating system, which means OS accessibility settings like prefers-reduced-motion are not available. Motion sensitivity support must come from your own settings menu: a player toggles a preference, your JS applies a class to the document root, and CSS animation rules respond to that class. This article walks through building that system.
The Problem with OS Media Queries
Section titled “The Problem with OS Media Queries”On web browsers, @media (prefers-reduced-motion: reduce) lets you conditionally disable animations based on the player’s OS accessibility setting. Because Gameface renders inside the game process and not in a standard browser context, this media query has no source signal to read. It will not trigger regardless of what the player has set in their OS.
The correct approach is to build motion preference support directly into the game settings:
- The player sets a “Reduced Motion” option in the settings menu.
- The game engine communicates this to the UI via
engine.trigger. - JavaScript adds or removes a class on the
<html>element. - CSS rules scoped to that class disable or simplify animations.
Setting Up the Root Class Toggle
Section titled “Setting Up the Root Class Toggle”A single class on the document root is the control point for all reduced-motion styles:
function setReducedMotion(enabled) { if (enabled) { document.documentElement.classList.add('motion-reduced'); } else { document.documentElement.classList.remove('motion-reduced'); }}
engine.on('ReducedMotionChanged', (enabled) => { setReducedMotion(enabled);});
// Restore saved preference on loadconst savedMotionPreference = engine.call('GetReducedMotionPreference') ?? false;setReducedMotion(savedMotionPreference);Once this is in place, every animation or transition in the codebase can be conditionally disabled by scoping it under .motion-reduced.
Disabling Animations and Transitions
Section titled “Disabling Animations and Transitions”The most complete approach is to turn off all transitions and animations globally when reduced motion is active, then selectively restore only the ones that are safe or necessary:
.motion-reduced *,.motion-reduced *::before,.motion-reduced *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important;}Using 0.01ms rather than 0ms ensures that animation events (animationend, transitionend) still fire, which matters if any of your JavaScript logic waits for those events before changing state.
For individual elements where you want fine-grained control rather than a global disable:
.screen-transition { transition: opacity 0.3s ease, transform 0.3s ease;}
.motion-reduced .screen-transition { transition: opacity 0.15s ease; transform: none;}This keeps the fade-in opacity transition (non-disorienting) but removes the slide transform (the source of motion).
Simplifying Parallax Effects
Section titled “Simplifying Parallax Effects”Parallax involves moving background layers at different speeds as the player interacts with a menu, creating a sense of depth. For players with vestibular disorders, this is one of the most disorienting effects in game UI.
A typical parallax implementation updates element transforms via JavaScript:
function updateParallax(mouseX, mouseY) { parallaxLayers.forEach((layer, index) => { const depth = (index + 1) * 0.02; const offsetX = (mouseX - viewportCenterX) * depth; const offsetY = (mouseY - viewportCenterY) * depth; layer.style.transform = `translate(${offsetX}px, ${offsetY}px)`; });}Guard this function against the reduced-motion state:
function updateParallax(mouseX, mouseY) { if (document.documentElement.classList.contains('motion-reduced')) { return; }
parallaxLayers.forEach((layer, index) => { const depth = (index + 1) * 0.02; const offsetX = (mouseX - viewportCenterX) * depth; const offsetY = (mouseY - viewportCenterY) * depth; layer.style.transform = `translate(${offsetX}px, ${offsetY}px)`; });}When the motion class is present, layers stay at their static positions. The effect disappears without any visual glitch because the transforms are simply never updated.
Handling Looping Animations
Section titled “Handling Looping Animations”Looping animations, such as idle shimmer effects, floating icons, or pulsing health bars, are persistent and therefore more likely to cause discomfort than single-play transitions.
Mark looping animations explicitly in CSS so they are easy to target:
.shimmer-effect { animation: shimmer 2s ease-in-out infinite;}
.float-animation { animation: float 3s ease-in-out infinite alternate;}
.pulse { animation: pulse 1.5s ease-in-out infinite;}
/* Disable all looping animations under reduced-motion mode */.motion-reduced .shimmer-effect,.motion-reduced .float-animation,.motion-reduced .pulse { animation: none;}Removing animation: none in reduced motion mode collapses these elements to their resting state. Ensure the resting state (no transform, no opacity shift) looks intentional and not broken before shipping.
Preserving Functional Animations
Section titled “Preserving Functional Animations”Not all animations are decorative. Some communicate critical state changes: a health bar depleting, an objective completing, an item being picked up. Disabling these entirely degrades usability.
The distinction to apply: decorative motion (parallax, shimmer, idle floats) should be completely disabled; functional feedback (state transitions, health warnings, notification entrances) should be simplified to opacity fades or instant state changes.
/* Decorative: disable */.motion-reduced .menu-background-parallax { transform: none; animation: none;}
/* Functional: simplify to opacity only, keep the feedback */.motion-reduced .health-bar-damage-flash { animation: healthFlashReduced 0.2s ease-out;}
@keyframes healthFlashReduced { from { opacity: 0.5; } to { opacity: 1; }}Providing Settings UI
Section titled “Providing Settings UI”The in-game settings toggle should be part of the accessibility section, not buried in graphics options. Present it clearly:
<div class="settings-row" role="group" aria-labelledby="motion-label"> <span id="motion-label" class="settings-label">Reduced Motion</span> <div class="toggle-control"> <button class="toggle-btn" id="motion-toggle" aria-pressed="false" aria-describedby="motion-desc" tabindex="0" > Off </button> <span id="motion-desc" class="settings-hint"> Disables parallax and looping animations. Motion in gameplay is unaffected. </span> </div></div>Update the button state when the preference changes:
const motionToggle = document.getElementById('motion-toggle');
motionToggle.addEventListener('click', () => { const isEnabled = motionToggle.getAttribute('aria-pressed') === 'true'; const newValue = !isEnabled;
motionToggle.setAttribute('aria-pressed', String(newValue)); motionToggle.textContent = newValue ? 'On' : 'Off';
setReducedMotion(newValue); engine.call('SaveReducedMotionPreference', newValue);});The aria-pressed attribute communicates the toggle state to TTS and screen readers. The descriptive hint text sets accurate player expectations: UI motion is reduced, but gameplay motion (camera shake, for example) is a separate setting.
© 2026 Coherent Labs. All rights reserved.