Перейти к содержимому

Анимации и адаптивная вёрстка: transition, keyframes, медиазапросы

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

В краткой версии ты добавлял transition: all 0.3s и один медиазапрос «под мобильные». Здесь разбираем: почему all — антипаттерн, как устроены кубики Безье timing-функций, какие свойства проходят через compositor без перерисовки, почему анимации должны выключаться при prefers-reduced-motion, и как строить адаптив от контента, а не от устройств.

transition описывает, как элемент перетекает между двумя значениями свойства при изменении (hover, класс, JS-стиль):

.btn {
transition: background-color 0.2s ease-in-out,
transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
/* подсвойства: property | duration | timing-function | delay */
}
Подсвойство Что задаёт Пример
transition-property что анимируем (список, НЕ all) opacity, transform
transition-duration длительность 0.2s, 200ms
transition-timing-function кривая ускорения ease, cubic-bezier(…)
transition-delay задержка старта 0.1s

cubic-bezier(x1, y1, x2, y2) описывает кривую в координатах «время → прогресс». Ось X — время (всегда 0→1), ось Y — доля завершения (может выходить за 0..1 — отсюда «пружинящие» эффекты). Кривые удобно проектировать визуально — в интерактивном редакторе cubic-bezier.com:

линейная: ease (по умолчанию): cubic-bezier(.34,1.56,.64,1) «пружина»:
y│ y│ y│ ╭─▶ прогресс > 1
1├──────── 1├──────── 1├───────╯ (перелёт и возврат)
│ ╱ │ ╭── │ ╭─╯
│ ╱ │ ╱ │ ╱
│ ╱ │ ╱ │╱
0├╱─────── x 0├╱─────── x 0├─────── x
0 1 время 0 1 0 1
  • ease = быстро старт, плавный финиш — универсален, но «вялый» для микровзаимодействий.
  • ease-out — быстрый старт, затухание: стандарт для появления элементов (карточки, тултипы) — интерфейс реагирует мгновенно.
  • ease-in — медленный старт, быстрый финиш: только для исчезновения, иначе ощущается «залипанием».
  • cubic-bezier(0.34, 1.56, 0.64, 1) — лёгкий перелёт через цель и возврат: живая «пружина» для кнопок и бейджей.

delay используется и для стаггер-эффектов (каскад появления списка: --i индекс × 30ms), и как ловушка: большая задержка делает интерфейс «ленивым», а на тач-устройствах hover-переходы с delay вообще не чувствуются.

Браузер рисует страницу в три стадии:

JS/CSS-изменение
┌─────────────┐ layout (reflow): пересчёт геометрии — ВСЁ: width, height,
│ STYLE │ top, margin, font-size, добавление узла…
└─────────────┘ │
│ ▼
┌─────────────┐ paint (repaint): перерисовка пикселей отдельных слоёв:
│ LAYOUT │ background, color, border, box-shadow…
└─────────────┘ │
│ ▼
┌─────────────┐ composite: сборка готовых слоёв на GPU:
│ PAINT │ transform, opacity — БЕЗ layout/paint
└─────────────┘
Экран

Цена стадий растёт экспоненциально: composite почти бесплатен, layout на большой странице — десятки миллисекунд. Отсюда золотое правило: анимируй transform и opacity; всё, что вызывает layout (width, height, top, left, margin, font-size), меняй мгновенно или через FLIP-приёмы. Подтверждения и инструменты замера — в web.dev: Animations and performance.

/* плохо: top/left гоняют layout на каждом кадре */
.ball { transition: top 0.5s, left 0.5s; }
/* хорошо: transform улетает на compositor */
.ball { transition: transform 0.5s; }
.ball--moved { transform: translate(200px, 100px); }

width/height иногда нужны анимировать (аккордеон). Компромисс — max-height от 0 до большого значения или современный interpolate-size: allow-keywords (поддержка ещё ограничена, проверяй).

Transition — между двумя состояниями; @keyframes — полный сценарий:

@keyframes pulse {
0% { transform: scale(1); opacity: 1 }
50% { transform: scale(1.05); opacity: 0.8 }
100% { transform: scale(1); opacity: 1 }
}
.spinner {
animation: pulse 2s ease-in-out infinite;
}
Подсвойство Значение Примечание
animation-name имя @keyframes
animation-duration длительность цикла
animation-timing-function кривая внутри keyframes можно менять по кадрам
animation-delay старт отрицательный — старт «из середины»
animation-iteration-count 1 / infinite / 3.5
animation-direction normal / reverse / alternate / alternate-reverse alternate: туда-обратно без рывка
animation-fill-mode none / forwards / backwards / both что до/после анимации
animation-play-state running / paused пауза по hover/JS

fill-mode — самое недопонятое:

  • backwards: до старта (в т.ч. в период delay) элемент уже в кадре 0%.
  • forwards: после финала элемент остаётся в кадре 100% (иначе мгновенно схлопнется обратно).

Отдельные анимации на hover должны быть короткими и обратимыми: при уходе мыши transition доиграет, а прерванная animation — перезапустится с нуля. Для hover-подсветок используй transition, keyframes оставь для автономных сценариев (лоадеры, привлечение внимания).

will-change: transform говорит браузеру: «держи элемент на отдельном слое заранее». Это убирает стоимость СОЗДАНИЯ слоя в первом кадре анимации. Но:

  • каждый слой — память GPU;
  • слои слишком мелких элементов дороже создания, чем выигрыш;
  • постоянный will-change на сотнях карточек — прямой путь к OOM на слабых устройствах.

Правило: will-change ставится точечно и временно (класс .is-animating на время анимации через JS), а не как постоянный атрибут раскладки.

Для части пользователей (вестибулярные расстройства, мигрени) параллакс, «пружины» и бесконечные пульсации вызывают тошноту и головную боль. ОС имеет переключатель «уменьшить движение» — и браузер отдаёт его в медиазапрос prefers-reduced-motion. Это обязательная доступность, а не опция:

.card { transition: transform 0.3s, box-shadow 0.3s; }
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgb(0 0 0 / 0.15); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

Декоративную анимацию выключаем полностью; функциональную (появление модалки) — заменяем на мгновенную смену состояния или простое затухание opacity. Каскадные сбросы через * — нормальная практика, их легитимность обоснована именно доступностью.

/* базовый синтаксис */
@media (min-width: 768px) { … } /* вьюпорт ≥ 768px */
@media (min-width: 768px) and (max-width: 1023.98px) { … } /* диапазон */
@media (orientation: landscape) { … }
@media (hover: hover) and (pointer: fine) { … } /* устройство с мышью */
/* операторы */
and — оба условия
, — ИЛИ: @media (max-width: 600px), (orientation: portrait)
only — старый хак скрытия от IE: @media only screen and (…)
not — инверсия (нельзя с not использовать only)

Подход определяет, от какого конца «растёт» раскладка:

MOBILE-FIRST (современный стандарт) DESKTOP-FIRST (легаси-подход)
база: мобильная (1 колонка) база: десктоп (4 колонки)
│ │
▼ min-width: 640px ▼ max-width: 1024px
планшет (2 колонки) планшет (2 колонки)
│ │
▼ min-width: 1024px ▼ max-width: 640px
десктоп (4 колонки) мобильная (1 колонка)
@media (min-width: 640px) { } @media (max-width: 1024px) { }
@media (min-width: 1024px) { } @media (max-width: 640px) { }

Почему mobile-first выиграл: мобильный трафик больше; «добавлять» сложность через min-width проще, чем «вычитать»; производительность: девайс получает только нужные стили без перекрытий. Грабля min-width-стиля: ширина ВКЛЮЧИТЕЛЬНА — (min-width: 768px) срабатывает ровно на 768, поэтому диапазоны пишут через max-width: 1023.98px (пол-пикселя против наложения). Полный синтаксис медиазапросов с операторами — в MDN: Использование медиазапросов.

«Телефон 375, планшет 768, десктоп 1024» — миф: устройств тысячи, и новый планшет выйдет раньше, чем ты пересоберёшь CSS. Правильный принцип: breakpoint ставится там, где раскладка ЛОМАЕТСЯ (строка текста выходит за 90 символов, карточки сжались до нечитаемости). Метод: уменьшай окно, следи, где верстка «хрустнула», — это и есть точка.

Тем не менее, типовые стартовые точки (Bootstrap-like), которые закрывают 95% кейсов:

Название Диапазон Что там происходит
xs < 576px телефоны, одноколоночный поток
sm ≥ 576px крупные телефоны/мелкие планшеты
md ≥ 768px планшеты, 2 колонки
lg ≥ 992px лэптопы, полная раскладка
xl ≥ 1200px десктопы, широкие сетки
xxl ≥ 1400px большие мониторы
@media (prefers-color-scheme: dark) { … } /* тёмная тема ОС */
@media (prefers-contrast: more) { … } /* высококонтрастный режим */
@media (prefers-reduced-motion: reduce) { … } /* см. выше */

Тёмная тема — не инверсия: используй кастомные свойства из главы fundamentals и переключай значения, сохраняя акценты и читаемость. prefers-contrast — для пользователей с ослабленным зрением: усиль границы и убери полупрозрачность.

body {
font-size: clamp(1rem, 0.6rem + 0.8vw, 1.25rem);
/* минимум предпочтение максимум */
}

Как это работает: предпочтение — линейная функция от vw (0.6rem + 0.8vw), clamp обрезает её снизу и сверху. Результат: плавное масштабирование между двумя «виртуальными breakpoint’ами» без единого медиазапроса. Формула предпочтения из двух точек (x1, y1) → (x2, y2):

slope = (y2 − y1) / (x2 − x1) в vw: slope × 100
pref = (y1 − slope × x1) в rem + slope × 100 vw

Например, 1rem при 400px → 1.25rem при 1200px: slope = 0.25/800 = 0.0003125 → clamp(1rem, 0.875rem + 0.3125vw, 1.25rem). Генераторы (utopia.fyi) считают за тебя; важно понимать механику, чтобы не копипастить магию.

Медиазапрос смотрит на ВЬЮПОРТ. Но карточка в сайдбаре и та же карточка в основной колонке должны выглядеть по-разному при ОДНОМ вьюпорте. Container query смотрит на размер КОНТЕЙНЕРА компонента:

.cards {
container-type: inline-size; /* контейнер измеряется по ширине */
container-name: cards;
}
.card__meta {
display: none; /* на узких — прячем мету */
}
@container cards (min-width: 400px) {
.card__meta { display: block }
.card { flex-direction: row } /* и перестраиваем карточку */
}

Один и тот же компонент адаптируется САМ — независимо от страницы, на которой оказался. Это конец эпохи «модификаторов .card–in-sidebar». Поддержка: все современные браузеры с 2023. Ограничение: нельзя стилизовать сам контейнер по его же query — только потомков; каскад отрабатывает как обычно (container queries живут внутри того же каскада). Синтаксис и примеры — в MDN: Container queries.

  1. Анимация width/height/top/left. Главный источник «дёрганого» интерфейса. Переводи на transform/opacity; для раскрытия блоков — max-height или grid-rows трюки.
  2. transition: all. Анимирует случайные свойства, мешает браузеру планировать слои, ломает perf-аудиты. Перечисляй явно.
  3. overflow-x: hidden на body «от прыжков». Часто маскирует переполнение от абсолютных элементов; лучше найти и починить источник вылезающего контента.
  4. Медиазапросы «под устройства» и 20 breakpoint’ов. Раскладка ломается в неожиданных местах между 361 и 374px — ставь точки по факту поломки, не по каталогам девайсов.
  5. hover-стили как единственный индикатор интерактивности. На тач-устройствах hover либо «залипает» после тапа, либо не срабатывает. Стилизуй и :focus-visible, и @media (hover: hover)-варианты.
  6. Отсутствие prefers-reduced-motion. Бесконечные анимации без этой обработки — прямой риск по accessibility-аудиту и реальной боли пользователей.
  7. will-change на всём подряд. Постоянные слои на сотнях элементов = память GPU + иногда ВРЕД производительности. Точечно, на время анимации.
  8. Мгновенные отключения анимаций через animation: none в reduced-motion. Лучше оставить функциональные микропереходы (opacity 0.01ms) — резкие «прыжки» состояния тоже дезориентируют.
  1. Почему transform/opacity предпочтительнее width/top для анимаций? Transform и opacity обрабатываются на этапе composite на GPU — без layout и paint. Width/top/left вызывают reflow (пересчёт геометрии) и repaint на каждом кадре, что на слабых устройствах даёт пропуски кадров.
  2. Что такое кубик Безье timing-function и что означают четыре числа? Кривая ускорения в координатах «время → прогресс»: точки управления (x1,y1) и (x2,y2), x — время (0..1), y — прогресс (может > 1 для «пружины»). ease-out = быстрый старт, плавный финиш.
  3. Чем transition отличается от animation? Transition — перетекание между двумя состояниями по триггеру (hover/класс), обратимо. Animation — полный сценарий @keyframes, с циклами, направлением, fill-mode; может работать автономно.
  4. Что делает animation-fill-mode: forwards и backwards? Forwards: после завершения элемент остаётся в состоянии последнего кадра. Backwards: до старта (включая период delay) элемент уже находится в состоянии первого кадра.
  5. Зачем нужен prefers-reduced-motion и как его обрабатывать? Отражает настройку ОС «уменьшить движение» для пользователей с вестибулярными расстройствами. Обязательная обработка: отключать/упрощать декоративные анимации, сохранять функциональные микропереходы.
  6. Mobile-first против desktop-first — в чём разница и что выбрать? Mobile-first: база под малые экраны, усложнение через min-width. Desktop-first: наоборот, max-width. Современный стандарт — mobile-first: больший трафик, аддитивная сложность, лучше производительность.
  7. Как работает clamp() для fluid-типографики? clamp(min, preferred, max): preferred — линейная функция vw+rem, clamp обрезает по границам. Получаем плавное масштабирование между двумя контрольными ширинами без медиазапросов.
  8. Чем container query отличается от медиазапроса? Медиазапрос реагирует на вьюпорт, container query — на размер контейнера компонента. Позволяет одному и тому же компоненту адаптироваться к месту на странице (сайдбар/основная колонка) независимо от устройства.
  1. Кривые ускорения. Кнопка: hover — подъём на 4px и тень за 200ms с «пружиной» cubic-bezier(0.34, 1.56, 0.64, 1); active — плавное «вжатие». Критерий: перелёт на hover чувствуется, на active — нет; всё на transform/box-shadow.
  2. Дорогая против дешёвой анимация. Сделай бокс, летящий по диагонали дважды: через top/left и через transform. Замерь в DevTools → Performance разницу в количестве layout-событий. Критерий: скриншот flame graph’а, где виден reflow в первом случае.
  3. Reduced motion. Страница с параллакс-фоном и hover-карточками. Добавь полный сброс анимаций при prefers-reduced-motion: reduce и проверь эмуляцией в DevTools → Rendering. Критерий: при включённой эмуляции ни одно свойство не анимируется.
  4. Fluid-типографика. Заголовок 1.5rem при 400px → 3rem при 1400px через clamp(). Выведи формулу preferred сам, сверь с utopia.fyi. Критерий: нет медиазапросов, размер строго в границах.
  5. Container query. Карточка, которая в узком контейнере показывает только заголовок, а от 400px — горизонтальный layout с описанием. Помести две такие карточки: в сайдбар 280px и в main 700px. Критерий: обе выглядят корректно при одном вьюпорте, без классов-модификаторов.