lenec ru

← все посты

Reduced motion на практике: как отключать анимации и не ломать UX

15K

Каждый раз, когда я в очередной проект внедряю поддержку 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 прописано что-то вроде масштабирования. Часто такие анимации забывают, потому что они «маленькие». На самом деле массовое раскачивание плиток в каталоге при движении мыши — это первый кандидат на жалобу от пользователей с вестибулярной чувствительностью.

Как тестировать

Просто включить системную настройку и кликать по интерфейсу — мало. Нужно убедиться, что вся анимация останавливается, ничего не телепортируется и контент остаётся читаемым. Я обычно прогоняю три сценария:

  1. В Chrome DevTools открыть Rendering panel (Ctrl+Shift+P → «Show Rendering»). Там есть переключатель Emulate CSS media feature prefers-reduced-motion. Удобно для быстрых проверок без перезагрузки страницы.
  2. В коде выполнить document.body.getAnimations() — вернёт все активные анимации на странице, включая CSS и Web Animations API. После эмуляции reduce motion список должен либо опустеть, либо содержать только essential-анимации.
  3. Прогнать 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. Этого достаточно, чтобы интерфейс перестал быть проблемой для людей с вестибулярными нарушениями. Дальше — токены и тесты.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.