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

Появление карточек на view timeline

Корневая шкала из прошлого урока описывала весь документ. Теперь добавим мягкое появление карточки по мере входа в видимую область. Используем view() как источник прогресса. Это отдельный механизм: положение элемента относительно scrollport определяет шкалу его видимости. В каталоге эффект остаётся небольшим и не скрывает ссылку полностью на исходной границе.

Ключевые кадры меняют прозрачность от 0.65 до единицы и смещение на несколько пикселей. В обычном CSS карточка непрозрачна и не сдвинута. Улучшение включается только при распознавании animation-timeline: view(). Такая основа полезна при неизвестной поддержке: отсутствие свойства не приводит к невидимому списку, ожидающему сценарий, которого в проекте нет.

Когда шкала относится к самой карточке

animation-range: entry 0% entry 100% ограничивает участок, на котором развиваются кадры. В данном примере нас интересует вход элемента, а не весь путь от первого появления до окончательного выхода. При длинной карточке геометрия диапазонов будет отличаться от привычного маленького блока. Не обещайте одинаковую долю текста на экране из одного только значения процентов.

Сокращение animation снова находится перед назначением шкалы и диапазона. CSS следует читать как последовательность деклараций каскада, а не как набор независимых строк. Когда позже общий стиль карточек задаст ещё одно animation, он может сбросить выбранный диапазон. Для передачи компонента полезно явно отметить, какие свойства составляют один договор эффекта.

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

Фильтр предыдущих этапов задаёт hidden неподходящим карточкам. После смены темы геометрия остальных меняется, поэтому их шкалы видимости могут получить другие значения. Это ожидаемая связь между layout и эффектом. Не храните отдельное число прогресса в сценарии, если единственный потребитель — CSS-анимация. Два источника состояния затруднят объяснение результата.

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

Ожидаемая разница проявится при прокрутке: карточка постепенно принимает своё обычное положение и полную непрозрачность. При прокрутке обратно её состояние вновь связано со шкалой, а не с флагом «анимация уже проиграна». Если нужен однократный показ, понадобится другой договор. Не добавляйте такой флаг только ради сходства с привычным reveal-скриптом.

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

Полный файл 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; } }
}

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

Видимость относительно scrollport не равна видимости для человека. Элемент может быть перекрыт другой панелью, пользователь может смотреть в другую область, а текст может оставаться слишком мелким. Поэтому view timeline не следует использовать как доказательство прочтения или внимания. В этом проекте она управляет только небольшим декоративным изменением.

Важно также положение sticky и fixed элементов. Если карточка позже перестанет быть обычным блоком потока, шкала её движения будет иметь другие условия. Не сохраняйте старый набор ожиданий после изменения layout. Новый дизайн может потребовать полностью убрать эффект, а не усложнять ключевые кадры в попытке имитировать прежнюю геометрию.

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

Анимация карточки и transition списка могут действовать в одной области. Это требует осторожного сочетания: один эффект представляет смену набора, другой зависит от видимости его элементов. Если интерфейс выглядит перегруженным, правильным решением может быть удаление одного эффекта. Не нужно сохранять все приёмы только потому, что каждый был изучен в отдельном уроке.

При высокой частоте изменений списка view timeline будет пересчитываться вместе с геометрией. Такое поведение соответствует модели, но может отвлекать от задачи выбора урока. Сначала сохраните простое содержание и устойчивый порядок, затем решите, улучшает ли движение ориентирование. Декоративное появление не должно маскировать задержку получения данных или служить оправданием пустой области.

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

Вариант с animation-range по entry раскрывает именно вход. Другой диапазон мог бы показать иной момент движения, но его нужно объяснить по геометрии. Не выбирайте проценты методом случайного подбора без понятного наблюдаемого результата. Читатель должен понимать, почему карточка изменилась в этой точке, и чем её шкала отличается от корневой полосы прошлого этапа.

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

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

Условие Ожидаемое состояние и его граница
Карточка входит в окно Кадры развиваются в диапазоне entry, а не по секундомеру.
Карточка очень высокая Геометрия диапазона требует отдельного наблюдения, универсальное совпадение не обещано.
Фильтр скрывает карточку Hidden удаляет её из отображения; остаточная анимация не должна создавать пустое место.
Ссылка получает фокус Содержание должно оставаться различимым при каждом доступном состоянии эффекта.
Требуется однократный показ Текущая шкала обратима; однократное действие является другой задачей.
Нужна аналитика показов View timeline не заменяет договор события, длительность видимости и получение согласия.

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

Первичные источники: Scroll timelines.

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