Accessible CSS Animation — Designing with prefers-reduced-motion
Senior guide to motion accessibility: the prefers-reduced-motion media query, opt-in vs global reset strategies, replacing motion with fades, matchMedia, and WCAG 2.3.3.
Motion is one of the most abused tools in modern UI. {Chuyển động là một trong những công cụ bị lạm dụng nhiều nhất trong UI hiện đại.} A tasteful parallax or a spring transition can feel delightful to one user and physically nauseating to another. {Một hiệu ứng parallax tinh tế hay một transition kiểu lò xo có thể thú vị với người này nhưng gây buồn nôn thật sự với người khác.} This guide treats motion as an accessibility concern first, and a design flourish second. {Bài viết này coi chuyển động trước hết là vấn đề về accessibility, sau đó mới là yếu tố thẩm mỹ.}
Detect your OS setting live, simulate reduced motion in-page, and compare removing animation vs replacing it with fades. {Phát hiện thiết lập OS theo thời gian thực, mô phỏng reduced motion ngay trên trang, và so sánh việc bỏ animation với việc thay bằng fade.}
Open the full demo {Mở demo đầy đủ}: /tools/css-reduced-motion-demo/.
Why motion is an accessibility concern
Animation is not neutral. For a significant slice of users it ranges from distracting to debilitating. {Animation không hề trung tính. Với một bộ phận đáng kể người dùng, nó dao động từ gây xao nhãng đến gây suy nhược.}
- Vestibular disorders — the inner-ear system that controls balance can be triggered by large-scale movement on screen, producing dizziness, vertigo, and nausea. {Rối loạn tiền đình — hệ thống tai trong điều khiển thăng bằng có thể bị kích hoạt bởi chuyển động lớn trên màn hình, gây chóng mặt, hoa mắt và buồn nôn.}
- Motion sickness — parallax, zoom, and continuous scroll-jacking simulate self-motion that conflicts with what the body feels, the same mechanism behind car sickness. {Say chuyển động — parallax, zoom và scroll-jacking liên tục mô phỏng cảm giác tự di chuyển, xung đột với cảm nhận của cơ thể, cùng cơ chế với say xe.}
- Distraction and cognitive load — users with ADHD, vestibular migraines, or anxiety can be pulled off-task by anything that moves, autoplays, or pulses. {Xao nhãng và tải nhận thức — người dùng có ADHD, đau nửa đầu tiền đình hay lo âu dễ bị kéo khỏi tác vụ bởi bất cứ thứ gì chuyển động, tự phát hay nhấp nháy.}
The key insight: motion sensitivity is invisible. {Điểm mấu chốt: sự nhạy cảm với chuyển động là vô hình.} You cannot detect it from a user agent string, so the platform exposes an explicit user preference instead. {Bạn không thể phát hiện nó từ user agent string, nên nền tảng cung cấp một tùy chọn rõ ràng của người dùng để thay thế.}
The prefers-reduced-motion media query
prefers-reduced-motion is a CSS media feature that mirrors an OS-level accessibility setting. {prefers-reduced-motion là một media feature của CSS phản ánh một thiết lập accessibility ở cấp hệ điều hành.} It has two values: {Nó có hai giá trị:}
no-preference— the user has expressed no preference; animate freely. {no-preference— người dùng không bày tỏ ưu tiên; bạn có thể animate thoải mái.}reduce— the user has requested that the system minimize non-essential motion. {reduce— người dùng yêu cầu hệ thống giảm thiểu chuyển động không thiết yếu.}
/* Fires only when the user is OK with motion. */
@media (prefers-reduced-motion: no-preference) {
.card {
transition: transform 200ms ease;
}
}
/* Fires when the user asked to reduce motion. */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
A subtle but important point: there is no “I want lots of motion” value. {Một điểm tinh tế nhưng quan trọng: không có giá trị “tôi muốn thật nhiều chuyển động”.} The query only tells you whether the user opted out. {Media query chỉ cho bạn biết người dùng có chọn tắt hay không.} Design defaults accordingly — calm by default, expressive only when allowed. {Hãy thiết kế mặc định theo đó — mặc định nhẹ nhàng, biểu cảm chỉ khi được cho phép.}
Two strategies: opt-in vs global reset
There are two dominant approaches, and they encode opposite philosophies. {Có hai cách tiếp cận chủ đạo, và chúng thể hiện hai triết lý đối lập.}
Strategy 1 — Opt-in (animate only on no-preference)
You write no animation by default, then layer it inside a no-preference block. {Bạn không viết animation nào theo mặc định, rồi thêm nó vào bên trong block no-preference.} This is the safest model because the accessible state is the default state. {Đây là mô hình an toàn nhất vì trạng thái accessible chính là trạng thái mặc định.}
.modal {
/* Final, resting styles only — no transition here. */
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
.modal {
animation: modal-in 220ms ease-out;
}
@keyframes modal-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
}
The win here is that a brand-new component is accessible the moment you ship it — you cannot forget to add the reduce guard, because motion is additive. {Điểm lợi là một component mới toanh sẽ accessible ngay khi bạn ship — bạn không thể quên thêm guard reduce, vì chuyển động ở đây là phần cộng thêm.}
Strategy 2 — Global reset (disable or shorten everything)
The pragmatic reality: large codebases already have hundreds of transitions scattered everywhere. {Thực tế thực dụng: các codebase lớn đã có hàng trăm transition rải rác khắp nơi.} A global reset is a safety net that neutralizes motion you might have missed. {Một global reset là tấm lưới an toàn để vô hiệu hóa những chuyển động bạn có thể đã bỏ sót.}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* Near-zero instead of 0 so JS `animationend`/`transitionend`
events still fire and state machines don't deadlock. */
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Two senior-level details in that snippet. {Hai chi tiết ở mức senior trong đoạn này.} First, we use 0.01ms, not 0: some JS relies on the transitionend / animationend event to advance UI state, and a true zero duration may never dispatch those events, deadlocking the flow. {Thứ nhất, ta dùng 0.01ms chứ không phải 0: một số đoạn JS dựa vào sự kiện transitionend / animationend để chuyển trạng thái UI, và duration bằng 0 thật sự có thể không bao giờ phát sự kiện đó, làm kẹt luồng xử lý.} Second, !important here is one of the few defensible uses — it is an accessibility override of last resort, not a specificity hack. {Thứ hai, !important ở đây là một trong số ít trường hợp chính đáng — nó là override accessibility ở mức cuối cùng, không phải mẹo specificity.}
Note on project conventions: this blog normally forbids
!important. The global reduced-motion reset is the documented exception, because it must beat every component-level rule by design. {Lưu ý về quy ước dự án: blog này thường cấm!important. Global reset cho reduced-motion là ngoại lệ được ghi nhận, vì nó buộc phải thắng mọi rule ở cấp component.}
Which one should you use?
Use both, in layers. {Hãy dùng cả hai, theo lớp.} Author new components opt-in, and keep a global reset as the floor that catches legacy or third-party CSS. {Viết component mới theo opt-in, đồng thời giữ một global reset làm sàn để bắt CSS cũ hoặc của bên thứ ba.} Opt-in is the intent; the reset is the insurance. {Opt-in là chủ đích; reset là bảo hiểm.}
Don’t remove feedback — replace motion with fades
A common mistake: equating “reduce motion” with “remove all transitions”. {Một sai lầm phổ biến: đánh đồng “giảm chuyển động” với “bỏ hết mọi transition”.} Reduced motion does not mean no feedback. {Giảm chuyển động không có nghĩa là không phản hồi.} A modal that snaps in with zero indication can be more disorienting than one that animates. {Một modal bật ra tức thì không dấu hiệu gì có thể gây mất phương hướng hơn cả một modal có animation.}
The accessible-friendly substitution is to swap positional/scale motion for opacity changes. {Cách thay thế thân thiện với accessibility là đổi chuyển động vị trí/tỉ lệ sang thay đổi opacity.} Cross-fades convey “something changed” without simulating self-motion. {Cross-fade truyền tải “có gì đó thay đổi” mà không mô phỏng cảm giác tự di chuyển.}
.toast {
opacity: 0;
}
/* Rich motion for users who allow it. */
@media (prefers-reduced-motion: no-preference) {
.toast {
transition:
opacity 200ms ease,
transform 200ms ease;
transform: translateY(12px);
}
.toast[data-open] {
opacity: 1;
transform: translateY(0);
}
}
/* Calm cross-fade only — feedback preserved, motion removed. */
@media (prefers-reduced-motion: reduce) {
.toast {
transition: opacity 200ms ease;
}
.toast[data-open] {
opacity: 1;
}
}
Vestibular triggers come from movement across the viewport and scaling, not from opacity. {Yếu tố kích hoạt tiền đình đến từ chuyển động ngang viewport và phóng to/thu nhỏ, không phải từ opacity.} So a fade is the right “reduced” equivalent of a slide. {Vì vậy fade là phiên bản “reduced” đúng đắn của một hiệu ứng trượt.}
Handling JS animations with matchMedia
CSS media queries cover declarative motion, but anything driven by JavaScript — requestAnimationFrame loops, the Web Animations API, a scroll library, a <canvas> confetti burst — is invisible to CSS. {Media query của CSS lo phần chuyển động khai báo, nhưng bất cứ thứ gì do JavaScript điều khiển — vòng lặp requestAnimationFrame, Web Animations API, thư viện scroll, hiệu ứng confetti trên <canvas> — đều vô hình với CSS.} Read the same preference from JS via matchMedia. {Hãy đọc cùng tùy chọn đó từ JS bằng matchMedia.}
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function applyMotionPreference() {
if (motionQuery.matches) {
// User wants reduced motion: skip the animation, jump to end state.
element.style.opacity = '1';
} else {
element.animate(
[
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'none' },
],
{ duration: 220, easing: 'ease-out' }
);
}
}
// Run once on load.
applyMotionPreference();
// React live if the user toggles the OS setting mid-session.
motionQuery.addEventListener('change', applyMotionPreference);
Two things people get wrong here. {Hai điều người ta thường làm sai ở đây.} First, they query the preference once on load and never listen for change; users do flip the setting, sometimes specifically because your animation hurt them. {Thứ nhất, họ chỉ đọc preference một lần lúc tải và không lắng nghe sự kiện change; người dùng có bật/tắt thiết lập này, đôi khi chính vì animation của bạn làm họ khó chịu.} Second, they reach for the legacy addListener API — prefer the standard addEventListener('change', ...). {Thứ hai, họ dùng API cũ addListener — hãy ưu tiên chuẩn addEventListener('change', ...).}
A reusable helper keeps call sites clean. {Một helper tái sử dụng giúp nơi gọi gọn gàng.}
/** Single source of truth for motion preference across the app. */
export function prefersReducedMotion(): boolean {
// Guard for SSR / non-browser environments.
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function onMotionPreferenceChange(
callback: (reduced: boolean) => void
): () => void {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (event: MediaQueryListEvent) => callback(event.matches);
query.addEventListener('change', handler);
// Return an unsubscribe function for cleanup.
return () => query.removeEventListener('change', handler);
}
scroll-behavior and parallax
Two web features are disproportionately likely to trigger vestibular reactions: smooth scrolling and parallax. {Hai tính năng web có xác suất cao gây phản ứng tiền đình bất thường: smooth scroll và parallax.}
scroll-behavior: smooth animates the jump when you click an in-page anchor. {scroll-behavior: smooth tạo animation cho cú nhảy khi bạn bấm vào anchor trong trang.} For long pages this can be a large, fast viewport sweep — exactly the kind of motion that hurts. {Với trang dài, đây có thể là một cú quét viewport lớn và nhanh — đúng loại chuyển động gây khó chịu.} Gate it. {Hãy kiểm soát nó.}
html {
scroll-behavior: auto;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
Parallax is harder because it usually lives in JS scroll handlers. {Parallax khó hơn vì nó thường nằm trong các scroll handler bằng JS.} The fix is to short-circuit the effect entirely when motion is reduced — render the layers in their neutral position and skip the transform-on-scroll loop. {Cách sửa là vô hiệu hóa hẳn hiệu ứng khi motion bị giảm — render các lớp ở vị trí trung tính và bỏ qua vòng lặp transform-theo-scroll.}
function initParallax(layer) {
if (prefersReducedMotion()) return; // Bail out: no parallax at all.
window.addEventListener(
'scroll',
() => {
const offset = window.scrollY * 0.3;
layer.style.transform = `translate3d(0, ${offset}px, 0)`;
},
{ passive: true }
);
}
Decorative auto-playing background video and infinite marquees fall in the same bucket — pause or hide them under reduce. {Video nền tự phát mang tính trang trí và marquee chạy vô hạn cũng thuộc nhóm này — hãy tạm dừng hoặc ẩn chúng khi reduce.}
Relationship to prefers-reduced-data
prefers-reduced-motion has a younger sibling: prefers-reduced-data, which signals that the user is on a metered or slow connection and wants to minimize data usage. {prefers-reduced-motion có một “người em”: prefers-reduced-data, báo hiệu người dùng đang dùng mạng tính dung lượng hoặc chậm và muốn giảm tối đa lượng dữ liệu.} They overlap in one place: heavy decorative media. {Chúng giao nhau ở một điểm: media trang trí nặng.} A looping hero video is both a motion concern and a data concern. {Một video hero lặp vừa là vấn đề chuyển động vừa là vấn đề dữ liệu.}
/* Drop the expensive animated background under either preference. */
@media (prefers-reduced-motion: reduce), (prefers-reduced-data: reduce) {
.hero {
background-image: url('/images/hero-static.avif');
}
.hero video {
display: none;
}
}
Treat them as related-but-distinct axes: reduced-motion is about vestibular safety, reduced-data is about bandwidth and cost. {Hãy coi chúng là hai trục liên quan nhưng khác biệt: reduced-motion về an toàn tiền đình, reduced-data về băng thông và chi phí.} prefers-reduced-data still has limited support, so always provide a non-media fallback. {prefers-reduced-data vẫn còn hỗ trợ hạn chế, nên luôn cung cấp fallback không phụ thuộc media query.}
WCAG: 2.3.3 Animation from Interactions
The Web Content Accessibility Guidelines codify this. {Bộ hướng dẫn WCAG đã quy định rõ điều này.}
- 2.3.3 Animation from Interactions (Level AAA) — motion animation triggered by interaction can be disabled, unless the animation is essential to the functionality or information being conveyed. {2.3.3 Animation from Interactions (mức AAA) — animation chuyển động kích hoạt bởi tương tác phải có thể tắt được, trừ khi animation là thiết yếu cho chức năng hoặc thông tin đang truyền tải.} This is exactly what honoring
prefers-reduced-motionsatisfies. {Đây chính là điều mà việc tôn trọngprefers-reduced-motionđáp ứng.} - 2.2.2 Pause, Stop, Hide (Level A) — any moving, blinking, or auto-updating content that starts automatically and lasts more than five seconds must have a way to pause, stop, or hide it. {2.2.2 Pause, Stop, Hide (mức A) — mọi nội dung chuyển động, nhấp nháy hoặc tự cập nhật bắt đầu tự động và kéo dài hơn năm giây phải có cách tạm dừng, dừng hoặc ẩn.}
- 2.3.1 Three Flashes or Below (Level A) — nothing should flash more than three times per second, to avoid triggering seizures. {2.3.1 Three Flashes or Below (mức A) — không có gì được nhấp nháy quá ba lần mỗi giây, để tránh kích hoạt co giật.}
The word “essential” in 2.3.3 matters. {Từ “essential” (thiết yếu) trong 2.3.3 rất quan trọng.} A loading spinner that communicates progress can stay; a parallax that exists purely for flair must go under reduce. {Một spinner loading truyền đạt tiến trình thì được giữ; một parallax chỉ để làm đẹp thì phải tắt khi reduce.} When in doubt, ask: does this motion carry meaning, or just personality? {Khi phân vân, hãy hỏi: chuyển động này mang ý nghĩa, hay chỉ là cá tính?}
Testing across OS settings
Reduced motion only works if you can actually exercise both states. {Reduced motion chỉ hữu ích nếu bạn thật sự test được cả hai trạng thái.} Know where the toggle lives on each platform. {Hãy biết công tắc nằm ở đâu trên từng nền tảng.}
- macOS — System Settings → Accessibility → Display → Reduce motion. {macOS — System Settings → Accessibility → Display → Reduce motion.}
- Windows — Settings → Accessibility → Visual effects → Animation effects off. {Windows — Settings → Accessibility → Visual effects → tắt Animation effects.}
- iOS — Settings → Accessibility → Motion → Reduce Motion. {iOS — Settings → Accessibility → Motion → Reduce Motion.}
- Android — Settings → Accessibility → Remove animations. {Android — Settings → Accessibility → Remove animations.}
For day-to-day development you don’t need to touch OS settings — Chromium and Firefox DevTools can emulate the preference. {Cho công việc hằng ngày, bạn không cần đụng vào thiết lập OS — DevTools của Chromium và Firefox có thể giả lập tùy chọn này.} In Chrome: open the Command Menu (Cmd/Ctrl + Shift + P) and run “Emulate CSS prefers-reduced-motion”. {Trên Chrome: mở Command Menu (Cmd/Ctrl + Shift + P) rồi chạy “Emulate CSS prefers-reduced-motion”.}
You can also assert behavior automatically in tests. {Bạn cũng có thể kiểm thử hành vi tự động.} Playwright exposes the preference directly. {Playwright cung cấp tùy chọn này trực tiếp.}
import { test, expect } from '@playwright/test';
test('modal does not slide when motion is reduced', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
await page.getByRole('button', { name: 'Open' }).click();
const modal = page.getByRole('dialog');
// Under reduce, the modal should be at its resting transform immediately.
await expect(modal).toHaveCSS('transform', 'none');
});
Finally, validate with the actual matchMedia value in a unit test by mocking window.matchMedia, since jsdom does not implement it. {Cuối cùng, hãy kiểm chứng bằng chính giá trị matchMedia trong unit test bằng cách mock window.matchMedia, vì jsdom không hiện thực nó.}
Checklist
- Default to calm: no motion unless
no-preference. {Mặc định nhẹ nhàng: không chuyển động trừ khino-preference.} - Keep a global
reducereset using0.01ms, not0. {Giữ một global reset choreducedùng0.01ms, không phải0.} - Replace slides/zooms with opacity cross-fades; never strip feedback entirely. {Thay slide/zoom bằng cross-fade opacity; đừng bao giờ bỏ hẳn phản hồi.}
- Mirror the CSS query in JS via
matchMediaand listen forchange. {Phản chiếu media query của CSS sang JS bằngmatchMediavà lắng nghechange.} - Gate
scroll-behavior: smoothand bail out of parallax underreduce. {Kiểm soátscroll-behavior: smoothvà thoát parallax khireduce.} - Drop heavy decorative media under reduced-motion or reduced-data. {Bỏ media trang trí nặng khi reduced-motion hoặc reduced-data.}
- Test on real OS toggles, DevTools emulation, and automated suites. {Test trên công tắc OS thật, giả lập DevTools, và bộ test tự động.}
Accessible motion is not less motion for everyone — it is the right motion for each user. {Chuyển động accessible không phải là ít chuyển động cho tất cả — mà là chuyển động đúng cho từng người dùng.}