jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 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-motion satisfies. {Đây chính là điều mà việc tôn trọng prefers-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ừ khi no-preference.}
  • Keep a global reduce reset using 0.01ms, not 0. {Giữ một global reset cho reduce dùng 0.01ms, không phải 0.}
  • 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 matchMedia and listen for change. {Phản chiếu media query của CSS sang JS bằng matchMedia và lắng nghe change.}
  • Gate scroll-behavior: smooth and bail out of parallax under reduce. {Kiểm soát scroll-behavior: smooth và thoát parallax khi reduce.}
  • 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.}

Further reading