Полоса чтения с прокруткой документа
Добавим тонкую декоративную полосу у верхней границы каталога. Она будет отражать продвижение по прокручиваемому документу. Вместо обработчика scroll используем scroll-driven animation. Смысл урока — связать изменение одного CSS-свойства с положением прокрутки и оставить чтение независимым от этой возможности. Наличие полосы не подтверждает, что человек прочитал текст.
Обычная анимация развивается по времени. Здесь её шкала связана с прокруткой. scroll(root block) выбирает корневой прокручиваемый документ и блочную ось. Пользователь прокручивает страницу — браузер меняет прогресс анимации. Не нужно каждый раз вычислять отношение scrollTop к разнице высот и назначать стили из JavaScript.
Прокрутка как временная шкала
Полоса помечена aria-hidden="true", поскольку она не является обязательным сообщением о состоянии. В каталоге нет задачи прочитать точный процент для подтверждения действия. Если продукт требует числового прогресса, следует отдельно определить, что именно измеряется, и представить значение доступным текстом. Сам CSS-эффект не даёт такого редакционного смысла.
В базовой ветке элемент скрыт. Внутри @supports (animation-timeline: scroll()) он становится фиксированной полосой с собственной анимацией. Такая деградация не оставляет пустую шкалу, которая выглядит как зависшее вычисление. Страница без поддержки просто не получает дополнительный индикатор, а ссылки и список материалов сохраняют состояние.
Ключевые кадры меняют transform: scaleX от нуля до единицы. Начало преобразования находится слева. Это соответствует направлению показанной русской страницы, но для интерфейса справа налево потребуется осознанно выбрать визуальное направление. Использование блочной оси шкалы не делает автоматически правильным любое направление горизонтальной полосы.
Порядок CSS-деклараций имеет значение. Сначала задано сокращённое animation, затем animation-timeline. Сокращение может сбросить связанные значения, поэтому перестановка строк в попытке аккуратно сгруппировать свойства способна изменить результат. Когда эффект не появляется, проверьте вычисленные значения, а не только наличие ключевых кадров.
Если документ короче окна и не прокручивается, нет привычного диапазона продвижения. Не превращайте это в сообщение «ничего не прочитано». Для маленького набора из трёх карточек полоса может почти не дать заметного изменения. При будущем просмотре допустимо увеличить объём описаний, сохранив те же ссылки, чтобы исследовать шкалу. Такой временный набор нужен для наблюдения, не для искусственного растягивания настоящей страницы.
Фиксированная полоса не должна закрывать кнопки, ссылка пропуска и верхний край контента должны оставаться видимыми. Толщина выбрана небольшой, а декоративный элемент не перехватывает смысл интерфейса. Если в проекте появляется фиксированный хедер, придётся согласовать область размещения. Задание z-index не заменяет расчёт полезного пространства.
Ожидаемый эффект — плавное изменение ширины окрашенной полосы при движении по длинному документу. Частота кадров, затраты и поведение на конкретном телефоне не измерялись. Отсутствие scroll-обработчика уменьшает количество собственного кода, однако не доказывает нулевую стоимость рисования. В следующем уроке рассмотрим шкалу видимости отдельной карточки, где точкой отсчёта является уже другой объект.
Полный файл 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); } }
}
Разбор изменения каталога
Шкала связана с прокручиваемой областью, а не со временем после открытия вкладки. Если посетитель отошёл от компьютера, полоса не должна постепенно дойти до конца сама. Это принципиальное отличие от обычной линейной анимации. Аналогично изменение размера окна может изменить отношение позиции к длине документа без дополнительного действия пользователя.
Для вложенного прокручиваемого блока root был бы неправильным источником. В этом уроке каталог находится в обычном документе, поэтому выбранная связь ясна. Если позже панель получит отдельную прокрутку, нельзя автоматически использовать её как шкалу всего чтения. Нужно назвать конкретный объект и согласовать границы. Тонкая полоса возле хедера не объясняет их сама по себе.
Для самостоятельного варианта поместите длинное описание ниже карточек и сравните начало, середину и конец страницы. Затем сократите описание так, чтобы прокрутка почти исчезла. Оцените, нужна ли полоса в таком состоянии вообще. Цифры длительности карточек не должны участвовать в расчёте: они описывают содержание, а не геометрический диапазон корневой прокрутки.
Шкала не загружает данные и не создаёт новый поток JavaScript-событий для каждого изменения позиции. Однако браузер всё равно выполняет вычисление и отображение. Поэтому слово «нативный» не является измерением производительности. Выбранный transform обычно даёт понятный визуальный приём, но реальный ресурсный расход следует рассматривать на конкретном устройстве и выпуске.
Не назначаем progress роли, потому что не описали доступную числовую задачу. Если интерфейс действительно показывает процент выполнения курса, его нужно получить из данных обучения, а не из положения страницы. Две разные модели могут существовать рядом, но должны иметь разные подписи. Иначе посетитель примет декоративную полосу чтения за подтверждение завершения материала.
Корневая шкала также меняется при появлении нового текста и изображений. Размеры ресурсов поэтому полезно объявлять заранее. Если изображение сдвигает документ после загрузки, позиция относительно общего диапазона может измениться без прокрутки. Это не ошибка анимации сама по себе; это следствие изменившейся геометрии, которую следует учитывать при дизайне.
Для будущего журнала выберите три положения документа и сохраните описание текущего размера окна. Сравнение без этого условия затруднит объяснение разницы. Не используйте случайный процент полосы как числовую эталонную проверку текста. В данном этапе наблюдение должно подтвердить связь с прокруткой и сохранение чтения, а не придать шкале редакционную точность, которой нет.
Условия для самостоятельного наблюдения
Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.
| Условие | Ожидаемое состояние и его граница |
|---|---|
| Длинный документ | Прогресс зависит от положения корневой прокрутки вдоль блочной оси. |
| Документ помещается | Отсутствие диапазона не следует интерпретировать как состояние чтения пользователя. |
| Поддержки нет | Декоративный элемент остаётся скрытым, место под него не резервируется. |
| Шкала прокручена назад | Эффект должен следовать за текущим положением, а не только один раз доиграть по времени. |
| Изменена ширина окна | Нужно рассматривать новый диапазон прокрутки после переразмещения текста. |
| Полоса нужна для отчёта | CSS-анимацию нельзя выдавать за подтверждённый процент прочтения материала. |
Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-08.
Первичные источники: Scroll timelines.
Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.