Reduced motion на практике: как отключать анимации и не ломать UX
Каждый раз, когда я в очередной проект внедряю поддержку prefers-reduced-motion, повторяется одна и та же история: разработчики либо забивают на запрос вообще, либо включают тотальную блокировку всех transition на странице. Оба варианта — плохие. Первый раздражает людей с вестибулярными нарушениями, второй превращает интерфейс в стопку статичных карточек, по которым непонятно, куда что переехало.
В этой статье — что я вынесла из внедрения reduced motion в дизайн-системе на 200+ компонентов: какие анимации действительно надо выключать, какие лучше оставить, и как написать это так, чтобы потом не возвращаться править каждую кнопку отдельно.
Что вообще обещает prefers-reduced-motion
Медиазапрос prefers-reduced-motion отражает системную настройку. На macOS — System Settings → Accessibility → Display → Reduce motion. На Windows — Settings → Accessibility → Visual effects → Animation effects. На iOS и Android — в разделе Accessibility/Specials. Если пользователь её включил, значит ему мешают анимации: либо физически (укачивает, болит голова), либо когнитивно (сложно следить за движущимся контентом).
Важный момент: запрос не говорит «выключи всё движение в принципе». Спецификация WCAG 2.3.3 формулирует мягче — убрать несущественную анимацию. То есть преобразование UI, без которого можно обойтись. Анимация, передающая смысл (прогресс загрузки, направление перехода), допустима, если её нельзя заменить статичным аналогом.
Базовый CSS: не блокируй, замедляй
Самый частый совет в интернете — обнулить все transition и animation:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Этот сниппет везде. И он реально решает проблему укачивания. Но у него есть побочка: интерфейс становится дёрганым. Модалки телепортируются, выпадайки появляются вспышкой, скролл к якорю обрывается.
В дизайн-системе, над которой я работала, мы пошли по другому пути: не запрещаем движение, а ограничиваем его дистанцию и амплитуду. Длительность остаётся, но трансформации превращаются в плавный fade.
:root {
--motion-duration-fast: 150ms;
--motion-duration-base: 240ms;
--motion-distance: 8px;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration-fast: 0ms;
--motion-duration-base: 0ms;
--motion-distance: 0px;
}
}Тогда конкретный компонент пишется через переменные:
.popover {
opacity: 0;
transform: translateY(calc(var(--motion-distance) * -1));
transition:
opacity var(--motion-duration-base) ease-out,
transform var(--motion-duration-base) ease-out;
}
.popover[data-open="true"] {
opacity: 1;
transform: translateY(0);
}Что получается. У обычного пользователя — плавное появление поповера со сдвигом 8 пикселей. У того, кто включил reduce motion — поповер просто появляется без движения. Без телепортов, без вспышек, без !important поверх всего проекта.
Какие анимации можно оставить
Не каждая анимация — зло. Спецификация явно разрешает оставлять движение, если оно essential: то есть несёт информацию, которую нельзя передать иначе.
- Индикаторы загрузки. Спиннер, который вращается, или прогресс-бар, который растёт — допустимо. Альтернатива — статичный текст «Загрузка...», но это деградация UX.
- Скелетоны. Тут спорно. Лёгкое мерцание (shimmer) лучше отключить, оставив сплошную заливку.
- Опциональные декорации. Параллакс, hover-эффекты на карточках, плавающие иконки — выключай, не задумываясь.
- Видео и GIF. Это не CSS-анимация, но критерий тот же. Автовоспроизведение зацикленных видео при reduced motion — отключай.
JavaScript: матчмедиа и реактивность
Когда анимации управляются через JS (Framer Motion, GSAP, requestAnimationFrame), CSS-медиазапрос их не остановит. Нужно проверять матчем:
const reducedMotionQuery = window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
function animateScrollTo(target: number) {
if (reducedMotionQuery.matches) {
window.scrollTo(0, target);
return;
}
// плавный скролл
window.scrollTo({ top: target, behavior: "smooth" });
}В React удобно завернуть в хук. Только не пиши useEffect для производных значений — состояние медиазапроса можно подписать через useSyncExternalStore:
import { useSyncExternalStore } from "react";
const query = "(prefers-reduced-motion: reduce)";
function subscribe(callback: () => void) {
const mql = window.matchMedia(query);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function getSnapshot() {
return window.matchMedia(query).matches;
}
function getServerSnapshot() {
return false;
}
export function usePrefersReducedMotion() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}Теперь компонент сам реагирует, если пользователь переключил настройку прямо в системе во время сессии. Я проверяла — в macOS такое случается чаще, чем кажется: люди отключают reduce motion на время игры в браузере и забывают вернуть.
Где обычно ломается
За последние два года я разбирала десятки баг-репортов по reduced motion. Самые частые проблемы:
Анимация на нативном scroll-behavior
Если в CSS написано html { scroll-behavior: smooth; }, то клик по якорю или вызов element.scrollIntoView() анимируются. С reduce motion это надо отключать руками:
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}Анимации на View Transitions API
Это новая штука, на момент написания работает в Chromium и Safari TP. Браузер сам учитывает prefers-reduced-motion и заменяет анимацию переходов на cross-fade. Но если ты в CSS вручную переопределил ::view-transition-*, то твои переопределения тоже надо обернуть в медиазапрос. Иначе пользователь получит свои свистящие переходы вне зависимости от настроек.
Lottie и сторонние анимации
Любой плеер Lottie/Rive не знает про твой матчмедиа. У lottie-web есть метод stop(), у Rive — pause(). Подвязывай к матчмедиа на маунте и при изменении.
CSS-анимации на :hover
Хитрый случай. Сам hover не вызывает реального движения — только если в transition прописано что-то вроде масштабирования. Часто такие анимации забывают, потому что они «маленькие». На самом деле массовое раскачивание плиток в каталоге при движении мыши — это первый кандидат на жалобу от пользователей с вестибулярной чувствительностью.
Как тестировать
Просто включить системную настройку и кликать по интерфейсу — мало. Нужно убедиться, что вся анимация останавливается, ничего не телепортируется и контент остаётся читаемым. Я обычно прогоняю три сценария:
- В Chrome DevTools открыть Rendering panel (Ctrl+Shift+P → «Show Rendering»). Там есть переключатель Emulate CSS media feature prefers-reduced-motion. Удобно для быстрых проверок без перезагрузки страницы.
- В коде выполнить
document.body.getAnimations()— вернёт все активные анимации на странице, включая CSS и Web Animations API. После эмуляции reduce motion список должен либо опустеть, либо содержать только essential-анимации. - Прогнать e2e-тестом через Playwright. У браузера есть опция
reducedMotion: 'reduce'вpage.emulateMedia(). Хорошо ловит регрессии, когда кто-то добавил CSS-анимацию мимо токенов.
test("popover не двигается при reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/playground/popover");
const popover = page.getByTestId("popover");
await page.getByRole("button", { name: "Открыть" }).click();
const animations = await popover.evaluate((el) =>
el.getAnimations().map((a) => a.playState)
);
expect(animations.every((s) => s === "idle" || s === "finished")).toBe(true);
});Что унести с собой
Поддержка reduced motion — не сниппет на десять строк, который копируется в подвал глобального CSS. Это одно из требований дизайн-системы наравне с контрастностью и фокус-стилями. Когда мы переехали с тотального обнуления transition на токены с переменными длительности и дистанции, количество багов «почему модалка дёргается у меня в системе» упало до нуля. И код стал предсказуемым: анимация в любом компоненте либо подчиняется токенам, либо это очевидная ошибка ревью.
Если бюджет совсем маленький — начни с трёх вещей: убери scroll-behavior: smooth при reduce motion, отключи параллакс и автоиграющие видео, замени декоративные scale-hover на opacity. Этого достаточно, чтобы интерфейс перестал быть проблемой для людей с вестибулярными нарушениями. Дальше — токены и тесты.