Перейти к содержанию

Согласование движения и пользовательских настроек

Каталог уже содержит несколько необязательных эффектов. Настройка prefers-reduced-motion должна согласовать их поведение. Урок не добавляет новую анимацию: он задаёт понятную ветку, в которой пользователь получает то же содержимое без лишнего движения. Проверим решение на уровне исходников и подготовим наблюдаемые условия для будущего ручного просмотра.

В обработчике фильтра предпочтение читается при каждом действии. Если значение равно reduce, список изменяется прямо. Это не отключает сам фильтр и не меняет результаты: две карточки контента остаются двумя карточками. Настройка относится к способу показа, а не к доступности функции. Частая ошибка — спрятать управляющую кнопку вместе с эффектом, хотя её задача всё ещё нужна.

Один договор для всех видов движения

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

Псевдоэлементы View Transition получают animation: none. Это относится и к снимкам, которые создаёт браузер при переходе между документами. В JavaScript мы избегаем запроса эффекта для фильтра; для cross-document навигации основное правило остаётся, но созданное представление не должно добавлять анимацию. Обе стороны поддерживают выбранный способ чтения.

Не используем универсальную декларацию, уменьшающую длительность каждой анимации до почти нуля на всём сайте. Она может затронуть компонент, где событие окончания связано с иной логикой, либо испортить независимый виджет. В этом маленьком проекте известны конкретные потребители. Правило перечисляет их и сохраняет управляемые границы.

Reduced motion не обязательно означает желание убрать каждый цветовой переход или любое изменение изображения. Тем не менее наши перемещения, появление и снимки не нужны для основной задачи, поэтому консервативная ветка их исключает. Если движение передаёт важные данные, эти данные следует повторить другой формой: текстом, подписью, устойчивым состоянием. Ссылка и количество материалов в каталоге уже существуют отдельно от эффектов.

Медиазапрос имеет смысл проверять и до открытия страницы, и после изменения системной настройки. Перезагрузка не должна быть обязательным способом применить предпочтение к новой фильтрации. Для CSS движок пересчитывает условие; для обработчика мы читаем актуальное значение перед действием. Если в будущем появится длинная самостоятельная анимация, нужно будет определить, как остановить уже выполняющийся экземпляр.

Предпочтение не является тестом здоровья и не должно записываться в профиль как личный вывод о человеке. Нам достаточно выбрать поведение интерфейса в текущей среде. Никакой аналитики этих настроек в проект не добавлено. Такой минимальный договор помогает уважать выбор, не превращая обычную адаптацию в сбор лишних признаков.

Ожидаемое состояние ветки reduce: фильтр действует мгновенно, карточки полностью видны, полосы нет, обычные ссылки переходят к материалам. Сравнение нужно проводить на том же наборе данных. Если после отключения движения исчезла часть списка или справка, это ошибка связи между функцией и оформлением. Тексты и исходники подготовлены без браузерного подтверждения; реальные результаты просмотра ещё предстоит заполнить.

Полный файл index.html текущего этапа:

<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Учебный каталог</title><link rel="stylesheet" href="styles.css"><script type="module" src="app.js"></script></head>
<body><a href="#main">Перейти к материалам</a><header><a href="index.html">Учебный каталог</a></header><main id="main"><h1>Учебные материалы</h1><p>Каталог «Полка»: статьи о контенте и интерфейсах.</p><div class="reading-progress" aria-hidden="true"></div><button type="button" id="open-lesson" hidden>О проекте урока</button>
<dialog id="lesson-dialog" aria-labelledby="dialog-title"><h2 id="dialog-title">Учебная библиотека</h2><p>Три урока с постоянными ссылками. Карточка дополняет каталог.</p><form method="dialog"><button value="close" autofocus>Закрыть</button></form></dialog><button type="button" id="help-button" popovertarget="help" hidden>Как читать каталог</button>
<aside id="help"><h2>Чтение каталога</h2><p>Откройте название урока. Справка не закрывает доступ к другим ссылкам.</p></aside><nav id="filters" aria-label="Фильтр уроков" hidden><button type="button" data-filter="all" aria-pressed="true">Все</button> <button type="button" data-filter="content" aria-pressed="false">Контент</button> <button type="button" data-filter="interface" aria-pressed="false">Интерфейс</button></nav><p id="filter-status" role="status"></p><ul class="cards"><li class="card" data-topic="content" data-minutes="20"><h2><a href="article.html#markdown">Markdown и структура урока</a></h2><p class="card-description">Разделяем содержание и оформление библиотеки.</p><p class="card-meta">Контент · 20 минут</p></li>
<li class="card" data-topic="interface" data-minutes="35"><h2><a href="article.html#navigation">Навигация учебного каталога</a></h2><p class="card-description">Сохраняем обычные ссылки и последовательность чтения.</p><p class="card-meta">Интерфейс · 35 минут</p></li>
<li class="card" data-topic="content" data-minutes="25"><h2><a href="article.html#urls">Постоянные адреса материалов</a></h2><p class="card-description">Меняем каталог, сохраняя адрес каждой статьи.</p><p class="card-meta">Контент · 25 минут</p></li></ul><p class="notice">Каждый урок доступен по обычной ссылке. Фильтры и эффекты дополняют чтение.</p></main><footer>Полка · учебный проект</footer></body></html>

Полный файл app.js текущего этапа:

document.documentElement.dataset.enhanced = 'true';
const lessonDialog = document.querySelector('#lesson-dialog');
const openLesson = document.querySelector('#open-lesson');
if (typeof lessonDialog.showModal === 'function') {
  openLesson.hidden = false;
  openLesson.addEventListener('click', () => {
    if (!lessonDialog.open) lessonDialog.showModal();
  });
  lessonDialog.addEventListener('close', () => openLesson.focus());
}
if ('popover' in HTMLElement.prototype) {
  document.querySelector('#help').setAttribute('popover', 'auto');
  document.querySelector('#help-button').hidden = false;
}
const filters = document.querySelector('#filters');
filters.hidden = false;
const applyFilter = (topic) => {
  let count = 0;
  document.querySelectorAll('.card').forEach((card) => {
    card.hidden = topic !== 'all' && card.dataset.topic !== topic;
    if (!card.hidden) count += 1;
  });
  filters.querySelectorAll('button').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.filter === topic)));
  document.querySelector('#filter-status').textContent = `Показано уроков: ${count}`;
};
let transitionBusy = false;
filters.addEventListener('click', async (event) => {
  const button = event.target.closest('button[data-filter]');
  if (!button || transitionBusy) return;
  const topic = button.dataset.filter;
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduceMotion) { applyFilter(topic); return; }
  transitionBusy = true;
  try {
    const transition = document.startViewTransition(() => applyFilter(topic));
    await transition.finished;
  } catch { applyFilter(topic); }
  finally { transitionBusy = false; }
});

Полный файл styles.css текущего этапа:

* { box-sizing: border-box; }
:root { color-scheme: light; --accent: #6d28d9; --paper: #fff; --ink: #20202a; }
body { margin: 0; color: var(--ink); background: #f7f6fb; font: 1rem/1.6 system-ui, sans-serif; }
header, main, footer { max-width: 72rem; margin-inline: auto; padding: 1.5rem; }
a { color: #5923b5; text-underline-offset: .18em; }
button { font: inherit; padding: .5rem .9rem; cursor: pointer; }
:focus-visible { outline: 3px solid #17693d; outline-offset: 4px; }
[hidden] { display: none !important; }
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); list-style: none; padding: 0; }
.card, aside { padding: 1.25rem; border: 1px solid #ddd8e8; border-radius: .7rem; background: white; }
.card h2 { margin-block-start: 0; }
.card-meta { color: #51495f; }
.notice { border-inline-start: .25rem solid var(--accent); padding-inline-start: 1rem; }
dialog { width: min(36rem, calc(100vw - 2rem)); border: 1px solid #b9afca; padding: 1.5rem; border-radius: .8rem; }
dialog::backdrop { background: rgb(20 15 30 / .5); }

@supports (anchor-name: --catalog-help) and (position-area: bottom) {
  #help-button { anchor-name: --catalog-help; }
  #help[popover] { position: fixed; position-anchor: --catalog-help; position-area: bottom; inset: auto; margin: .5rem; width: min(25rem, calc(100vw - 2rem)); }
}

@supports (position-try-fallbacks: flip-block) {
  #help[popover] { position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; max-block-size: calc(100dvh - 2rem); overflow: auto; }
}

.cards { view-transition-name: catalog-list; }
::view-transition-old(catalog-list), ::view-transition-new(catalog-list) { animation-duration: 180ms; }

@view-transition { navigation: auto; }
header { view-transition-name: catalog-header; }

.reading-progress { display: none; }
@supports (animation-timeline: scroll()) {
  .reading-progress { display: block; position: fixed; inset: 0 0 auto; height: .2rem; background: #17693d; transform-origin: left; animation: reading linear; animation-timeline: scroll(root block); z-index: 1; }
  @keyframes reading { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

@supports (animation-timeline: view()) {
  .card { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
  @keyframes reveal { from { opacity: .65; transform: translateY(.4rem); } to { opacity: 1; transform: none; } }
}

@media (prefers-reduced-motion: reduce) {
  .card, .reading-progress { animation: none; transform: none; opacity: 1; }
  .reading-progress { display: none; }
  ::view-transition-old(*), ::view-transition-new(*) { animation: none; }
}

Разбор изменения каталога

Настройка движения должна оставаться общей политикой интерфейса. Если новая панель позже получит собственный slide-in, её нужно добавить в тот же договор, а не считать отдельным исключением по умолчанию. Полезно записать роль эффекта: декоративная связь, обратная связь действия или существенное представление данных. Это помогает определить подходящую альтернативу без движения.

При будущем просмотре не сравнивайте только скриншоты. Ветка reduce может визуально выглядеть так же после завершения анимации, хотя во время действия был сильный переход. Нужно рассмотреть сам маршрут: активацию фильтра, вход карточки и смену документа. Конечное состояние показывает полноту данных, но не раскрывает способ, которым человек к нему пришёл.

Для самостоятельного варианта включите настройку во время открытого каталога, затем смените тему. После этого откройте статью и вернитесь назад. Проверьте, что счётчик и ссылки остаются теми же, а необязательное движение не становится условием обработки действия. Результаты фиксируйте как будущие наблюдения с указанием среды. При написании курса такая последовательность не исполнялась, поэтому нельзя отметить её автоматически пройденной.

Нельзя проверять пользовательское предпочтение один раз на сервере и навсегда записывать его в статический HTML. Настройка относится к среде посещения и может измениться после подготовки выпуска. В нашем проекте CSS и обработчик читают её в браузере. Это сохраняет локальное решение без необходимости создавать отдельные версии каждой статьи для разных системных настроек.

Текст сообщения фильтра остаётся видимым и в спокойной ветке. Он сообщает результат действия, тогда как эффект объяснял визуальную смену. Если убрать сообщение вместе с анимацией, человек получит менее понятную обратную связь именно в состоянии, где движения нет. Разделение роли данных и роли оформления помогает избежать такого непреднамеренного ухудшения.

Предпочтение движения также не означает желание отключить фокусные контуры или снизить контраст. Эти признаки помогают читать и управлять страницей, а не создают декоративное перемещение. Поэтому медиаветка перечисляет анимационные элементы, не меняя все визуальные правила сразу. Узкое изменение легче проверить и объяснить, чем глобальное выключение оформления.

Для командной передачи полезно включить reduce в требования каждого нового компонента. Если эффект появился в отдельном файле, он всё ещё должен следовать общей политике. План просмотра фиксирует и обычное, и спокойное состояние. Нельзя отмечать второе пройденным только потому, что браузер распознал медиазапрос: нужно рассмотреть фактическое действие и видимость обязательного результата.

Условия для самостоятельного наблюдения

Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.

Условие Ожидаемое состояние и его граница
Настройка включена заранее Начальная страница показывает обычные непрозрачные карточки без декоративной полосы.
Настройка изменена позже Новый клик по фильтру читает текущее предпочтение через matchMedia.
Есть два результата фильтра Количество карточек сохраняется независимо от разрешения движения.
Открыта справка Управление popover остаётся доступным; медиаветка не скрывает содержание панели.
Обычный переход к статье Смысл href и фрагмента не зависит от анимации снимков.
Нужно передать важное состояние Его следует выразить текстом или устойчивым признаком, а не одним эффектом.

Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-10.

Первичные источники: Reduced motion, View Transition API.

Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.