Ключевые кадры: @keyframes и animation

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

@keyframes — это именованный сценарий анимации, набор «снимков» свойств в разные моменты её хода, от 0% до 100%.

transition хорош для «было → стало», но у него ровно две точки и нужен внешний триггер. Когда требуется загрузочный спиннер, пульсирующая точка уведомления или многоэтапное появление — нужны @keyframes и свойство animation. Они работают сами, без :hover и без JavaScript.

Зачем это на практике

Бесконечное вращение, мигание, «дыхание» кнопки, последовательное появление пунктов меню — всё это анимации без явного начального и конечного состояния DOM. Их нельзя выразить через transition, потому что там нет переключения класса в нужный момент. Ключевые кадры решают задачу декларативно.

Объявляем сценарий

Сначала описываем сам сценарий — что и в какой момент времени делает свойство. Проценты — это доля от полной длительности анимации.

@keyframes fade-in-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

from и to — синонимы 0% и 100%. Затем привязываем сценарий к элементу:

.toast {
  animation-name: fade-in-up;
  animation-duration: 0.4s;
  animation-timing-function: ease-out;
}

Свойства animation

СвойствоЧто задаёт
animation-nameимя сценария из @keyframes
animation-durationдлительность одного прохода
animation-timing-functionкривая скорости (как у transition)
animation-delayзадержка перед стартом
animation-iteration-countсколько раз повторить; infinite — бесконечно
animation-directionнаправление: normal, reverse, alternate
animation-fill-modeкакие стили держать до старта и после конца
animation-play-staterunning или paused — пауза анимации

Многошаговая анимация

Сила @keyframes — в произвольных промежуточных кадрах. Опишем подпрыгивающий мячик: вверх он замедляется, вниз ускоряется, в точке касания «сплющивается».

@keyframes bounce {
  0%   { transform: translateY(0) scaleY(1); }
  40%  { transform: translateY(-60px) scaleY(1); }
  55%  { transform: translateY(-60px) scaleY(1); }
  100% { transform: translateY(0) scaleY(0.92); }
}
.ball {
  animation: bounce 0.9s ease-in-out infinite alternate;
}

Здесь сразу видно сокращённую запись: animation: name duration timing-function iteration-count direction. Несколько кадров с одинаковым значением (40% и 55%) создают паузу «в верхней точке».

Бесконечный цикл

Классический спиннер — вращение по кругу без конца. Здесь нужны именно infinite и linear: с ease вращение бы дёргалось на стыке циклов.

@keyframes spin {
  to { transform: rotate(360deg); }
}
.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

Достаточно описать только to — начальное состояние браузер возьмёт из текущих стилей элемента (поворот 0deg).

direction и fill-mode на практике

animation-direction: alternate заставляет анимацию проигрываться вперёд, затем назад, и так по кругу — идеально для пульсации, чтобы не было резкого скачка к началу.

animation-fill-mode решает важную проблему: по умолчанию до старта и после конца элемент показан своими обычными стилями, игнорируя кадры. Если анимация с задержкой, элемент успеет «мигнуть» конечным видом перед стартом. Значения:

  • forwards — после конца оставить стили последнего кадра (частый выбор для появлений).
  • backwards — во время задержки уже показать стили первого кадра.
  • both — и то и другое.
.menu-item {
  opacity: 0;
  animation: fade-in-up 0.3s ease-out forwards;
}
.menu-item:nth-child(2) { animation-delay: 0.08s; }
.menu-item:nth-child(3) { animation-delay: 0.16s; }

Без forwards пункты после анимации вернулись бы к opacity: 0 и исчезли. Разные задержки дают эффект «лесенки» — пункты появляются один за другим.

play-state и пауза

animation-play-state позволяет приостановить и возобновить анимацию, не сбрасывая её прогресс. Удобно для «бегущей строки», которая замирает под курсором, чтобы текст можно было прочитать:

.marquee {
  animation: scroll-left 12s linear infinite;
}
.marquee:hover {
  animation-play-state: paused; /* остановить, не теряя позицию */
}

Несколько анимаций на одном элементе

Как и у transition, у animation можно перечислить несколько сценариев через запятую — каждый со своими настройками. Это позволяет разложить сложное движение на независимые слои, например медленное вращение фона плюс быстрая пульсация:

@keyframes pulse { 50% { transform: scale(1.1); } }
.badge {
  animation:
    spin 6s linear infinite,
    pulse 1.2s ease-in-out infinite;
}

Браузер проигрывает их одновременно и независимо. Главное — чтобы они меняли разные свойства или согласованно работали с transform (две анимации, пишущие в transform разными функциями, конфликтуют — выигрывает последняя в списке).

Как это работает под капотом

Браузер раскладывает заданные кадры на временной шкале по процентам. Между соседними кадрами он интерполирует значения той же кривой timing-function, что и в переходах (причём кривую можно задать даже внутри отдельного кадра). В отличие от transition, анимация запускается, как только элемент с правилом animation появляется в DOM или к нему добавляется класс с этим правилом, — внешний «толчок» не нужен. Когда проходов больше одного, в конце каждого цикла значения мгновенно возвращаются к кадру 0% (если direction не alternate), что и вызывает «дёрганье» при неудачной кривой.

Частые ошибки

  • Забыли animation-fill-mode: forwards. Элемент после красивого появления возвращается к исходному виду.
  • Опечатка в animation-name. Имя не совпадает с @keyframes — анимации просто нет, без ошибки в консоли.
  • ease на бесконечном вращении. На стыке циклов скорость скачет; для infinite-вращений берите linear.
  • Тяжёлые свойства в кадрах. Анимация top/left/width в бесконечном цикле грузит процессор; используйте transform.

Итоги

  • @keyframes имя { ... } описывает сценарий, animation привязывает его к элементу.
  • Проценты кадров — доли от длительности; from/to равны 0%/100%.
  • iteration-count: infinite даёт бесконечный цикл, direction: alternate — движение туда-обратно.
  • fill-mode: forwards сохраняет финальный кадр после окончания.
  • Анимация стартует сама, без триггера, и поддерживает любое число промежуточных кадров.
Проверьте себя
1. Чем @keyframes-анимация принципиально отличается от transition?
AОна работает только по наведению мыши
BОна запускается сама, без смены состояния, и поддерживает много промежуточных кадров
CОна не умеет анимировать transform
DОна всегда длится ровно 1 секунду
2. Что делает animation-fill-mode: forwards?
AЗапускает анимацию в обратном направлении
BПовторяет анимацию бесконечно
CОставляет элемент в стилях последнего кадра после завершения анимации
DУскоряет анимацию вдвое
3. Какую timing-function стоит выбрать для бесконечно вращающегося спиннера?
Aease — чтобы было плавнее
Bease-in-out — для мягких концов
Clinear — чтобы скорость не скакала на стыке циклов
Dsteps(2) — для дискретности