Плавная смена фильтра с View Transitions
Каталог содержит две темы. Добавим кнопки «Все», «Контент» и «Интерфейс», которые скрывают неподходящие карточки. Затем обернём изменение в same-document View Transition. Основная операция остаётся фильтрацией уже существующих элементов; анимация лишь показывает переход между состояниями. Такой порядок позволяет сохранить действие в браузере без нового API.
Начальные карточки находятся в HTML, а панель фильтров скрыта до запуска сценария. После регистрации обработчиков управление появляется. Посетитель без JavaScript видит полный список и может читать статьи. Это осознанное упрощение: фильтр не передаётся серверу, не меняет адрес и не является единственным способом найти материал.
Снимки до и после изменения списка
Функция applyFilter получает тему и меняет hidden карточек. Проверка использует собственные data-topic, а не текст заголовка. Затем она обновляет aria-pressed у кнопок и короткое сообщение с role="status". В результате выбранное состояние выражено не одним цветом. Сообщение подтверждает количество показанных записей; оно не получает фокус и не прерывает пользователя переходом к новой области.
document.startViewTransition принимает функцию изменения DOM. Браузер снимает прежнее состояние, выполняет обновление и получает новое состояние для эффекта. Не переносите всё изменение в обработчик окончания анимации: тогда переход будет описывать снимки без нужного различия. Данные и доступная структура должны обновиться в предусмотренной фазе, а не зависеть от красивого завершения движения.
Список имеет одно view-transition-name: catalog-list. Имя связывает соответствующие представления в старом и новом снимках. Мы не назначаем это имя каждой карточке: повторение имени среди одновременно участвующих элементов может сорвать подготовку эффекта. Индивидуальные переходы потребуют уникальных устойчивых имён и отдельного решения о том, какие записи соответствуют друг другу.
Ветвление проверяет наличие метода и prefers-reduced-motion. Когда эффекты не подходят, applyFilter вызывается прямо. Значение настройки читается при каждом действии, чтобы не держать устаревшее решение после изменения системного предпочтения. Последующий CSS-этап согласует с этим условием другие виды движения, которые не запускаются через JavaScript.
Флаг transitionBusy не позволяет накапливать новые переходы до окончания текущего. Для небольшого каталога это понятный договор: повторное нажатие во время короткого эффекта пропускается. В производственном интерфейсе можно хранить последнюю желаемую тему и применять её после завершения. Главное — явно выбрать поведение, а не дать нескольким асинхронным операциям менять одно состояние в непредсказуемом порядке.
finished может завершиться с ошибкой при невозможности перехода. В этой ветке функция фильтра вызывается снова; она идемпотентна для выбранной темы, поэтому повтор не создаёт другую запись и не отправляет запрос. Состояние разблокируется в finally. Не распространяйте такой приём автоматически на оплату или запись данных: повторная мутация там требует отдельного договора.
Ожидаемый результат для темы контента — две видимые карточки и соответствующее сообщение. При отсутствии API состояние должно быть таким же, только без эффекта. Проверяйте прежде количество и выбранную кнопку, затем движение. Если начинать с оценки плавности, можно пропустить фильтр, который анимируется убедительно, но показывает неправильные материалы.
Полный файл 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><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; }
Разбор изменения каталога
Кнопки фильтра остаются на месте во время смены списка. Поэтому фокус не перемещается к первой видимой карточке без запроса пользователя. Самопроизвольный перенос мог бы сделать повторную смену темы неудобной. Сообщение о количестве дополняет выбранную кнопку, а открытие нужного урока остаётся следующим самостоятельным действием.
Набор данных не удаляется при фильтрации. Это упрощает возвращение к полной выборке, но такой подход не следует переносить на большие списки без оценки расходов. Тысячи скрытых карточек всё ещё находятся в DOM. Для крупной библиотеки серверная выдача или статические тематические страницы могут иметь более простой ресурсный договор. Эффект перехода не решает задачу объёма данных.
Для самостоятельного варианта добавьте четвёртую карточку и обновите ожидания числа результатов. Сам applyFilter не должен требовать изменения зашитого числа. Затем временно исключите startViewTransition из ветки и сравните смысл состояния. Если новый результат отличается, значит функция изменения и визуальная обёртка были связаны неправильно. Изменения должны быть эквивалентны по данным, хотя движение может отсутствовать.
Наблюдаемое количество материалов меняется синхронно с hidden. Поэтому сообщение формируется внутри одной функции, а не после окончания перехода. Если объявить новый счётчик только после анимации, пользователь мог бы видеть уже другой список с ещё старым сообщением. Данные состояния должны согласоваться независимо от того, успел ли браузер закончить декоративную часть.
Внутри callback нет сетевого запроса. Если фильтр позднее станет асинхронным, загрузку и commit нового набора нужно рассмотреть отдельно. Длинное ожидание в фазе обновления может привести к нежелательному состоянию снимков и интерфейса. Удобнее сначала получить данные и определить актуальность запроса, затем выполнить короткое согласованное изменение DOM. Ошибка сети при этом имеет собственную текстовую ветку.
Имя перехода не является ключом записи. catalog-list обозначает одну область представления, тогда как data-topic и ссылки выражают содержание. Не используйте название темы как имя сразу у всех её карточек: среди видимых элементов оно может повториться. Для индивидуального движения нужен устойчивый уникальный словарь, который также учитывает исчезнувшие и новые записи.
Блокировка повторных кликов выбрана для короткого учебного эффекта. Она не должна выглядеть как зависшая кнопка при длительной операции. Если в реальном продукте время станет заметным, нужно сообщить состояние и пересмотреть очередь действий. Здесь ожидается короткий переход, но время его фактического выполнения не измерено и не гарантируется одной CSS-длительностью.
Условия для самостоятельного наблюдения
Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.
| Условие | Ожидаемое состояние и его граница |
|---|---|
| Тема Контент | Видимыми остаются две карточки с data-topic content, сообщение показывает это число. |
| Тема Интерфейс | Видимой остаётся карточка навигации, остальные сохраняются в DOM с hidden. |
| Кнопка Все | Все три карточки снова доступны без пересоздания исходных ссылок. |
| API отсутствует | Функция фильтра вызывается напрямую и сохраняет тот же смысл состояния. |
| Reduced motion включён | Изменение производится без запроса к startViewTransition. |
| Нажатия идут подряд | Флаг занятости ограничивает пересечение операций; последнее нажатие не ставится в очередь. |
Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-06.
Первичные источники: View Transition API.
Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.