@keyframes: движение без повода
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Значок загрузки крутится, пока идёт загрузка, а заготовка карточки мерцает, пока нет данных. Никто не отмечал флажок и не наводил мышь: состояние не менялось, а движение есть. Такое движение без повода записывают ключевыми кадрами — правилом @keyframes. Разберём, как остановить анимацию на нужном кадре и почему без этого элемент возвращается в исходное положение, как заставить ехать переменную и где возможности CSS кончаются. И сразу — о людях, которым движение на экране мешает: это не украшение, а настройка, которую страница обязана уважать.
Переход едет между двумя состояниями, и запускает его перемена: отметили флажок — полоса поехала. У значка загрузки перемены нет, он просто крутится, и ждать повода ему неоткуда. Для такого движения нужны две вещи. Правило @keyframes даёт кадрам имя и записывает, каким элемент будет в начале и в конце: @keyframes grow { from { width: 0; } to { width: 200px; } }. Свойство animation вешает эти кадры на элемент: animation: grow 300ms linear — имя, длительность, плавность.
Бесконечная анимация в реальных проектах встречается почти в двух ролях. Первая — индикатор «идёт работа»: значок загрузки, полоса прогресса, вращение кнопки отправки, пока ждём ответа сервера. Вторая — заготовка вместо контента, скелетон: серые полосы мерцают там, где через секунду появится текст, и страница не выглядит сломанной, пока данные в пути. Обе живут, пока не придёт ответ, поэтому им ставят infinite, а снимают вместе с классом, который их включил. Всё остальное движение обычно конечное и по событию — и тогда чаще хватает перехода из прошлой статьи.
Анимация начинается сама, как только правило досталось элементу. Ни щелчка, ни замера перед ним, как у перехода, не нужно: «было» записано в from. Проверять движение будем тоже стоп-кадром. Отрицательная задержка -150ms запускает анимацию так, будто она идёт уже 150 мс, — при длительности 300ms это половина времени. Слово paused останавливает анимацию на этом месте. Без него она поехала бы дальше, и число зависело бы от того, сколько кадров успела нарисовать вкладка.
У двух полос ниже одинаковые кадры, длительность и стоп-кадр. Плавность linear указана только у первой, с классом even. Предскажите обе строки: полпути по времени — это полпути по дороге? И одинаково ли стоят полосы?
linear половина времени — ровно половина пути. Вторая стоит в другом месте. Плавность у animation, как и у перехода, по умолчанию ease: резкий разгон и долгое торможение, к середине времени — около четырёх пятых пути. Поэтому число статья печатает только для linear, а ease сравнивает через same(): в сотых долях пикселя Chrome и Firefox расходятся. Сами анимации в CSS — Baseline widely с 30.03.2018: пишите.Прежде чем крутить что-то дальше — о тех, кто на это смотрит. Людям с расстройствами вестибулярного аппарата крупное движение на экране приносит головокружение и тошноту, а бесконечное движение рядом с текстом мешает сосредоточиться — например, людям с синдромом дефицита внимания. Поэтому в системе есть настройка уменьшения движения: в macOS она в «Универсальном доступе», в Windows это выключатель эффектов анимации в специальных возможностях, есть она и на телефонах. Браузер передаёт её странице медиазапросом prefers-reduced-motion — и страница может на неё ответить.
Правила внутри @media (prefers-reduced-motion: reduce) { … } действуют, только когда настройка включена. Это Baseline widely с 15.07.2022. Свод правил доступности WCAG 2.2 в критерии 2.3.3, уровня AAA, требует, чтобы движение, запущенное действием человека, можно было отключить, если оно не нужно для смысла, — и медиазапрос как раз простой способ это выполнить. Решает человек, а автор только выбирает, что делать с его просьбой: выключить движение, замедлить или заменить спокойным.
Ниже значок загрузки и полоса прогресса. Значок крутится по-настоящему: у spin только кадр to с полным оборотом, rotate: 360deg, а начало браузер берёт из обычного стиля значка, где поворота нет. Слово infinite повторяет круг без конца. Полоса — стоп-кадр на половине, как в первом разделе. Правило в @media снимает анимацию со значка. Предскажите строку — и изменится ли она у читателя, у которого настройка включена?
@media касается только .spinner, полоса под него не попадает. А значок у каждого свой: в рамке он крутится, если настройка выключена, и стоит, если включена. Как раз поэтому ответ про значок зависит не от кода, а от вашего компьютера.Проверьте обе ветки сами, это занимает минуту. Включите в системе уменьшение движения и запустите пример снова: значок встанет. Без похода в настройки ту же просьбу умеет изобразить Chrome: в инструментах разработчика откройте вкладку Rendering — её находит поиск команд, Ctrl+Shift+P или Cmd+Shift+P, — и в пункте Emulate CSS media feature prefers-reduced-motion выберите prefers-reduced-motion: reduce.
Можно зайти и с другой стороны: включать движение только тем, кто меньше не просил, — правилами внутри @media (prefers-reduced-motion: no-preference) { … }. Какой путь ни выбери, прогресс и другие сведения оставляют на экране: выключается движение, а не информация.
Ту же настройку читает и JavaScript, методом matchMedia. В части II так решают, прокручивать плавно или сразу.
Кроме имени, длительности и плавности у animation есть ещё части. Каждая пишется словом в общей записи или отдельным свойством с приставкой animation-, как animation-delay в примерах. Из двух времён в общей записи первое — длительность, второе — задержка.
| Часть | Пример | Что задаёт |
|---|---|---|
| имя | grow | какие кадры играть — имя из @keyframes |
| длительность | 300ms, 1s | сколько длится один круг; по умолчанию 0s |
| плавность | ease, linear, linear() | как разложить путь по времени; по умолчанию ease |
| задержка | 200ms, -150ms | когда начать; отрицательная — начать с середины |
| число кругов | 3, infinite | сколько раз повторить; по умолчанию один |
| направление | reverse, alternate | играть задом наперёд или по очереди туда и обратно |
| заполнение | forwards, backwards, both | что показывать до начала и после конца; по умолчанию ничего |
| ход | running, paused | идёт анимация или стоит |
Главная ловушка — в строке «заполнение». Кадры действуют на элемент, только пока анимация идёт. Кончилась — и элемент возвращается к обычным стилям, будто кадров не было: полоса, выросшая до 200px, прыгает назад, и выглядит это как ошибка, хотя всё работает как написано. Слово forwards оставляет на месте последний кадр.
Проверим стоп-кадром после конца. Задержка -400ms при длительности 300ms ставит анимацию туда, где она уже 100 мс как закончилась. Обычная ширина у обеих полос — 50px, forwards только у второй. Предскажите строку.
backwards работает с другого конца: пока идёт положительная задержка, показывает первый кадр, а не обычный стиль. both делает и то и другое.У forwards есть обратная сторона, и она дороже самой ловушки. Значение анимации — и идущей, и удержанной — сильнее обычных правил: полосу с forwards не сузит класс с width: 30px, добавленный потом, и перебьёт её только !important. Поэтому итоговое значение часто пишут прямо в стиль элемента, а в кадрах оставляют только from: конец анимация возьмёт из обычного стиля, как spin выше брал из него начало, и после анимации элемент останется таким же.
Анимация кончилась — элемент вернулся к своим стилям. Нужен последний кадр навсегда — forwards, но удержанный кадр сильнее ваших обычных правил. Надёжнее записать итог в сам стиль элемента, а кадрам оставить from.
from и to — другие имена для 0% и 100%. Между ними можно поставить сколько угодно кадров в процентах времени: 50% { width: 50px; } значит «к половине времени — 50px». А похожую кривую умеет рисовать и плавность: функция linear() из статьи о переходах работает в анимации так же, linear(0, 0.25 50%, 1) — к половине времени четверть пути.
У трёх полос ниже стоп-кадр на половине времени. Первая едет по двум кадрам с linear. У второй те же два кадра, но плавность linear(). У третьей плавность linear, а кадров три, с серединой на 50px. Предскажите три ширины.
0.25 50% в плавности и кадр 50% рисуют один и тот же путь: первую половину времени медленно, вторую быстро. Разница в том, где путь записан. Кадр задаёт значения свойств, и в одном кадре можно поменять сразу несколько. Точка в linear() задаёт долю пути, и одну такую плавность можно отдать любым кадрам.И ловушка на стыке двух способов. Плавность действует не на всю анимацию, а на каждый отрезок между соседними кадрами. С ease и кадрами 0%, 50% и 100% полоса разгонится и затормозит дважды — на подходе к 50px и на подходе к 200px, — и движение выйдет дёрганым, хотя каждое число на своём месте. Нужна одна кривая на всю анимацию — оставьте два кадра и опишите путь плавностью.
Круг загрузки, который заполняется по часовой стрелке, рисуют градиентом: conic-gradient(steelblue 25%, silver 0) закрашивает синим первую четверть круга, а остальное серым. Сам градиент в кадрах не поедет: Chrome и Firefox середину между двумя градиентами не ищут, и он сменится скачком, как display. Ехать может число внутри градиента — если вынести его в переменную.
Свойство, чьё имя начинается с двух дефисов, например --p, — пользовательское. Сам браузер его ни к чему не применяет, а подставляет значение туда, где написано var(--p). Откуда такие свойства взялись и как ими пользуются, разберёт следующая тема главы — переменные CSS. Здесь важно одно: для браузера значение переменной — просто текст. В кадрах можно написать --p: 0% и --p: 100%, но что это проценты, браузер не знает и середину не ищет: на половине времени текст сменится скачком.
Правило @property регистрирует переменную и объявляет её тип. syntax: "<percentage>" — значение всегда проценты, inherits: false — не брать значение у родителя, initial-value: 0% — значение, пока никто не задал своего. Зная тип, браузер умеет считать середину и ведёт переменную плавно.
Ниже два одинаковых круга с одинаковыми кадрами, border-radius: 50% скругляет квадрат в круг. Стоп-кадр стоит на четверти времени. Круг .p закрашен зарегистрированной переменной --p, круг .q — переменной --q, которую не регистрировали. Предскажите обе строки.
--p ровно 25%: четверть времени при linear — четверть пути, и в рамке закрашена четверть круга. --q всё ещё 0%: незарегистрированная переменная держит первый кадр до середины времени и только тогда прыгает на 100%. Замените в записи animation слово paused на infinite и смотрите в рамку: первый круг раз за разом плавно заполняется, второй полсекунды серый, а полсекунды целиком синий.@property — Baseline newly с 09.07.2024: знайте о нём, но не делайте условием работы страницы. Браузер, который правила не знает, оставит переменную текстом, и круг заполнится скачком: выглядит хуже, смысл тот же.
Переменная без @property — текст: в анимации она прыгает на середине времени. С @property у неё есть тип, браузер ведёт её плавно, и вместе с ней едет всё, куда её подставили.
Ключевые кадры — запись, сделанная заранее: путь и время известны до первого кадра. Всё, что решается по ходу движения, CSS уже не анимирует.
| Что нужно | Почему кадры не справятся |
|---|---|
| пружина, которая помнит скорость | linear() рисует пружину заранее; прервите движение на полпути — новое начнётся без разгона, набранного старым |
| исчезновение при удалении | элемент, убранный из разметки, пропадает сразу: анимировать больше нечего |
| перестройка раскладки | соседа удалили — остальные прыгают на новые места, а не едут к ним |
| жест | полоса идёт за пальцем, а после броска летит с его скоростью: путь рисует человек, а не кадры |
Такое движение считает скрипт, кадр за кадром. В React, в части IV, это берёт на себя библиотека Motion — её разбирает статья про анимацию исчезновения и раскладки. Раньше она называлась Framer Motion, и под старым именем выходит тот же пакет, а в новых проектах ставят motion: в коде он появится как motion/react. Её автор сам разбирал, в чём CSS библиотеку догнал, — например, в появлении элемента, отдельных трансформациях и простых пружинах через linear(). А где не догнал: пружина из linear() не подхватывает скорость жеста или прерванного движения. Отсюда порядок: сначала CSS, библиотека — когда движение зависит от того, что делает человек.
Два соседних механизма есть не везде. Анимацию, которая идёт не по времени, а по прокрутке (animation-timeline), умеют Chrome 115+ и Safari 26, а Firefox — нет: это limited. Управлять анимацией из JavaScript — остановить, развернуть, дождаться конца — позволяет Web Animations API, Baseline widely с 16.03.2023. Это уже не пролог.
Как пружина устроена изнутри, покажет часть V, в React Native: сначала движение из значения, подписки и кадра, потом пружина без длительности в Animated. Там же — жесты: касание, сдвиг и свайп.
@keyframesзаписывает кадры под именем,animationвешает их на элемент: имя, длительность, плавность, задержка. Повод анимации не нужен — она идёт, как только правило досталось элементу. Baseline widely.- Анимацию проверяют стоп-кадром:
pausedи отрицательная задержка приlinear. Плавность по умолчанию —ease, и её сравнивают черезsame(), а не числом. @media (prefers-reduced-motion: reduce)— просьба человека двигать поменьше, и ответ у каждого читателя свой. Декоративное движение по ней выключают или смягчают, сведения оставляют.- Кончившаяся анимация отпускает элемент к обычным стилям.
forwardsдержит последний кадр,backwardsна время задержки показывает первый, но удержанный кадр сильнее ваших обычных правил. fromиto— это0%и100%, между ними ставят кадры в процентах, а пропущенный крайний кадр берётся из обычного стиля. Плавность действует на каждый отрезок между кадрами.- Переменная без
@property— текст и в анимации прыгает на середине; с зарегистрированным типом она едет, а с ней градиент (newly). Пружины со скоростью, исчезновение при удалении, раскладку и жесты кадры не анимируют — это работа скрипта.
1/4У полосы width: 50px и animation: grow 1s, кадры растят ширину от 0 до 200px. Какой ширины полоса через две секунды?
2/4Кадры from { --q: 0%; } и to { --q: 100%; }, длительность 1s, плавность linear, @property для --q нет. Что покажет стоп-кадр на 750 мс?
3/4Кадры 0%, 50% и 100%, плавность ease. Как едет полоса?
4/4В стилях есть @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }. Кто решает, крутится ли значок?
Уведомление видно до появления
Уведомление должно проявиться из прозрачного через 200 мс после вставки. Кадр у анимации один, from { opacity: 0; }: конец браузер берёт из обычного стиля, где прозрачности нет. Пауза с положительной задержкой ставит стоп-кадр внутри этих 200 мс, и прибор печатает 1: всё время ожидания уведомление видно целиком, потом пропадает и проявляется заново. Добавьте в запись animation одно слово, чтобы замер показал 0.
Во время задержки анимация ещё не началась, и элемент показывает обычный стиль — полную видимость. Первый кадр на время задержки ставит backwards:
Замер станет .toast → opacity: 0. Сработает и both: он добавит ещё и forwards, но последний кадр здесь и так совпадает с обычным стилем.
Туда и обратно
Полоса бесконечно растёт от 0 до 200px, по 300ms на круг. После каждого круга она прыгает к нулю и растёт снова, а должна плавно ехать обратно. Стоп-кадр стоит на 375 мс, и прибор печатает 50px. Добавьте в запись animation одно слово, чтобы полоса ездила туда и обратно, и заранее посчитайте, что тогда покажет тот же стоп-кадр.
Туда и обратно по очереди играет направление alternate:
Замер станет .bar → width: 150px. 375 мс — это весь первый круг и ещё 75 мс, четверть второго. С alternate первый круг идёт вперёд, второй — назад, от 200px к нулю, и за четверть круга полоса проезжает обратно четверть пути: 200 − 50 = 150.
Ширина на переменной
Ширину полосы задаёт переменная, width: var(--w), а кадры ведут --w от 0px до 200px. Стоп-кадр на четверти времени показывает 0px: полоса не растёт, а прыгает на середине. Добавьте одно правило, чтобы замер показал 50px.
Браузер не знает, что --w — длина, и середину не ищет. Тип объявляет @property:
Замер станет .bar → width: 50px: четверть времени при linear — четверть пути от 0 до 200px. Тип здесь <length>, а не <percentage>: в кадрах стоят пиксели.