Test motion preferences
Motion animation triggered by interaction can be disabled, unless the animation is essential to the functionality. Use prefers-reduced-motion media query.
Different animation types
Loading Spinner:
Attention Indicator:
Scroll Indicator:
Panel Transition:
⚠️ Auto-playing carousels should pause for reduced-motion users and provide controls to start/stop.
CSS and JavaScript approaches
/* Respect system preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Or target specific elements */
.spinner {
animation: spin 1s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
}// Check preference using matchMedia
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
// React hook example
function usePrefersReducedMotion() {
const [prefers, setPrefers] = useState(false);
useEffect(() => {
const mq = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
setPrefers(mq.matches);
const handler = (e) => setPrefers(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefers;
}<!-- motion-safe: only animates if NO preference -->
<div class="motion-safe:animate-spin">
<!-- motion-reduce: applies when reduced -->
<div class="animate-spin motion-reduce:animate-none">
<!-- Tailwind config -->
module.exports = {
theme: {
extend: {
animation: {
// Safe animations
'fade-in': 'fadeIn 0.2s ease-out',
}
}
}
}Risk levels
Testing hints
// Emulate reduced motion preference
await page.emulateMedia({
reducedMotion: 'reduce'
});
// Verify animations are disabled using $eval
const spinner = page.getByTestId('spinner');
const animation = await spinner.$eval(
'self', el => getComputedStyle(el).animationName
);
expect(animation).toBe('none');
// Check for animation-duration
const duration = await spinner.$eval(
'self', el => getComputedStyle(el).animationDuration
);
// Should be 0 or very small
expect(parseFloat(duration)).toBeLessThan(0.1);
// Test both modes
for (const motion of ['reduce', 'no-preference']) {
await page.emulateMedia({ reducedMotion: motion });
// Test behavior...
}// Find all animated elements using $$eval
const animated = await page.$$eval('*', els =>
els.filter(el => {
const style = getComputedStyle(el);
return style.animationName !== 'none' ||
parseFloat(style.transitionDuration) > 0.3;
}).map(el => ({
tag: el.tagName,
animation: getComputedStyle(el).animationName,
duration: getComputedStyle(el).animationDuration
}))
);
console.log('Elements with animation:', animated);
// Check if @media (prefers-reduced-motion)
// exists in stylesheets
const sheets = [...document.styleSheets];
const hasReducedMotionRule = sheets.some(sheet => {
try {
return [...sheet.cssRules].some(rule =>
rule.media?.mediaText.includes('prefers-reduced-motion')
);
} catch { return false; }
});Automation hints
page.emulateMedia({ reducedMotion: 'reduce' })getComputedStyle(el).animationName === 'none'