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.
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. 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. 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ỹ.
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.
Mở demo đầy đủ:
Why motion is an accessibility concern
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.
- 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.
- 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.
- 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.
Điểm mấu chốt: sự nhạy cảm với chuyển động là vô hình. 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 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. Nó có hai giá trị:
no-preference— người dùng không bày tỏ ưu tiên; bạn có thể animate thoải mái.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;
}
}
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”. Media query chỉ cho bạn biết người dùng có chọn tắt hay không. 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
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)
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. Đâ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;
}
}
}
Đ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)
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. 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;
}
}
Hai chi tiết ở mức senior trong đoạn này. 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ý. 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.
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?
Hãy dùng cả hai, theo lớp. 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 là chủ đích; reset là bảo hiểm.
Don’t remove feedback — replace motion with fades
Một sai lầm phổ biến: đánh đồng “giảm chuyển động” với “bỏ hết mọi transition”. Giảm chuyển động không có nghĩa là không phản hồi. 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.
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-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;
}
}
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. 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
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. 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);
Hai điều người ta thường làm sai ở đây. 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. Thứ hai, họ dùng API cũ addListener — hãy ưu tiên chuẩn addEventListener('change', ...).
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
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 tạo animation cho cú nhảy khi bạn bấm vào anchor trong trang. 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. Hãy kiểm soát nó.
html {
scroll-behavior: auto;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
Parallax khó hơn vì nó thường nằm trong các scroll handler bằng JS. 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 }
);
}
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 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. Chúng giao nhau ở một điểm: media trang trí nặng. 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;
}
}
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 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
Bộ hướng dẫn WCAG đã quy định rõ điều này.
- 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. Đây chính là điều mà việc tôn trọng
prefers-reduced-motionđáp ứng. - 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 (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.
Từ “essential” (thiết yếu) trong 2.3.3 rất quan trọng. 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. 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 chỉ hữu ích nếu bạn thật sự test được cả hai trạng thái. 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.
- Windows — Settings → Accessibility → Visual effects → tắt Animation effects.
- iOS — Settings → Accessibility → Motion → Reduce Motion.
- Android — Settings → Accessibility → Remove animations.
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. Trên Chrome: mở Command Menu (Cmd/Ctrl + Shift + P) rồi chạy “Emulate CSS prefers-reduced-motion”.
Bạn cũng có thể kiểm thử hành vi tự động. 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');
});
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
- Mặc định nhẹ nhàng: không chuyển động trừ khi
no-preference. - Giữ một global reset cho
reducedùng0.01ms, không phải0. - Thay slide/zoom bằng cross-fade opacity; đừng bao giờ bỏ hẳn phản hồi.
- Phản chiếu media query của CSS sang JS bằng
matchMediavà lắng nghechange. - Kiểm soát
scroll-behavior: smoothvà thoát parallax khireduce. - Bỏ media trang trí nặng khi reduced-motion hoặc reduced-data.
- Test trên công tắc OS thật, giả lập DevTools, và bộ test tự động.
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.