Переход: промежуточные кадры рисует браузер

стили≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Флажок отметили — полоса стала во всю ширину. Такую перемену браузер делает за один кадр, скачком: в одном кадре полосы нет, в следующем она уже полная. Одна строка transition просит его о другом — между «было» и «стало» нарисовать путь самому, а от вас нужно только сказать, что едет, сколько и как. Разберём, как проверить движение без секундомера, чем ease отличается от linear, что не поедет, сколько ни проси, и как плавно показать элемент, которого на странице только что не было.

Разделы темы:·

Любая перемена на странице — это два состояния. Флажок не отмечен — полоса пустая, отмечен — полная. Без подсказки браузер переключает их за один кадр: в одном полосы нет, в следующем она уже во всю длину. Свойство transition просит нарисовать промежуточные кадры, и ни координат, ни расписания кадров вы при этом не пишете.

Состояние в примерах статьи переключает флажок, и для этого нужны два новых кусочка языка селекторов. :checked — псевдокласс состояния: input:checked выбирает флажок, на котором стоит галочка. Знак + между селекторами значит «сосед, который стоит в разметке сразу после», поэтому input:checked + .bar читается как «полоса сразу за отмеченным флажком». Оба пригодятся и за пределами примеров: так в CSS делают меню, вкладки и аккордеоны без единой строчки скрипта.

Ниже две одинаковые полосы, но у второй есть transition: width 2s. Оба флажка отмечаются один за другим, и сразу после этого замеряется ширина. Предскажите вторую строку: где окажется каждая полоса в момент замера?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Первая прыгнула на 200px. Вторая стоит на нуле, хотя «стало» у неё то же самое: замер пришёлся на самый первый миг перехода, путь только начался. Посмотрите в рамку сразу после запуска — вторая полоса доезжает за две секунды. Щёлкните в рамке её флажок, и она так же плавно уедет обратно.

Запись transition складывается из четырёх частей:

ЧастьПримерЧто задаёт
свойствоwidth, opacityчто едет; all — все свойства, какие умеют ехать
длительность2s, 300msсколько длится путь; по умолчанию 0s, то есть перехода нет
плавностьease, linearкак разложить путь по времени; не указана — ease
задержка0s, -150msкогда начать; из двух времён в записи первое — длительность, второе — задержка

Разным свойствам можно дать разные переходы через запятую: transition: width 300ms, opacity 1s. Пишут transition в обычное правило элемента, а не в правило «стало»: оттуда он работает в обе стороны. Объявленный только для отмеченного флажка, переход сработает по пути туда, а обратно полоса прыгнет. Сами переходы — Baseline widely с 30.03.2018.

На настоящей странице состояние чаще переключает скрипт: добавляет элементу класс, и срабатывает правило, как наше input:checked + .bar. Переходу всё равно, кто сменил состояние, а как это делает скрипт, покажет часть II — переключение классов из JavaScript.

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

Стоп-кадр даёт задержка со знаком минус. Обычная задержка 150ms заставляет переход подождать, а -150ms запускает его так, будто он идёт уже 150 мс. При длительности 300ms это половина времени. Предскажите стартовую длину полосы на странице примера: начатый переход, как у медленной полосы выше, или 150 мс, что он будто бы проехал?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
100px, и сразу: переход с отрицательной задержкой уже в первый миг стоит на середине пути, кадра ждать не нужно. Chrome и Firefox здесь сходятся, поэтому все движения в статье проверяются так: плавность linear, чтобы доля времени равнялась доле пути, и задержка минус половина длительности.

Первый замер здесь не для сравнения, а чтобы переход вообще случился. Браузер считает стили не после каждой строки, а когда их спрашивают или когда рисует кадр; страница примера встаёт на сцену и щелчок идёт сразу за ней — без единого кадра между ними. Замер до щелчка заставляет браузер посчитать пустую полосу, и у неё появляется «было». Без этого замера стили впервые посчитались бы уже за отмеченным флажком, и полоса сразу оказалась бы на 200px: переходить было бы не из чего. Та же ловушка вернётся в конце статьи, когда элемент появляется на странице впервые.

Прогнозируем только стоп-кадр: отрицательная задержка и linear дают долю пути, известную заранее. Число посреди настоящего движения зависит от кадров вкладки, и прогноз с ним не сойдётся.

Функция плавности решает, какую долю пути отдать какой доле времени: одно и то же расстояние можно проехать равномерно, а можно рывком в начале и с торможением в конце. У каждой части записи transition есть и своё отдельное свойство, у плавности это transition-timing-function — им меняют одну часть, не переписывая остальные. У двух полос ниже одинаковые 300ms и одинаковый стоп-кадр на половине времени. У первой, с классом even, плавность linear, у второй не указана вовсе. Предскажите: сколько проехала первая и одинаково ли с ней вторая?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Первая на середине, 100px. Вторая — нет: без указания браузер берёт ease, а она резко разгоняется и долго тормозит у цели. К половине времени ease проходит около четырёх пятых пути, примерно 160px из 200. Само число пример нарочно не печатает: в сотых долях пикселя Chrome и Firefox расходятся (160,47px и 160,48px), поэтому здесь same().

У ease есть родня: ease-in медленно разгоняется, ease-out медленно тормозит, ease-in-out делает и то и другое, а cubic-bezier() задаёт свою кривую четырьмя числами. Кривая из четырёх чисел меняет направление не больше двух раз: проскочить цель, откатиться за неё и вернуться она ещё может, а качнуться у цели несколько раз, затухая, уже нет. Пружину с любым числом качаний пишут функцией linear() с точками.

На практике плавность выбирают не по вкусу, а по роли движения. Отклик на действие человека — нажатие кнопки, раскрытие меню — делают коротким, 100–200ms, и с торможением в конце: длинное ожидание ощущается как подтормаживание. Появление и уход элемента — чуть дольше, 200–300ms. А движение, которое человек должен успеть проследить, например перелёт карточки в корзину, делают заметным, до полусекунды. Всё, что дольше, начинает казаться медленным интерфейсом, даже если выглядит красиво.

Каждая точка в linear() — доля пути, после неё можно указать момент времени. linear(0, 1.25 50%, 0.95 75%, 1) значит: к половине времени проехать 125% пути, к трём четвертям откатиться до 95%, к концу встать ровно в цель. Между точками движение равномерное, а точек можно поставить сколько угодно. Цель у обеих полос ниже — 160px, у второй плавность-пружина. Предскажите вторую строку.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Первая полоса на половине пути — 80px. Вторая к той же половине времени проскочила цель на четверть и стоит на 200px, а дальше откатится и встанет на 160px. Раньше такую пружину считали скриптом кадр за кадром; linear() — Baseline widely с 11.06.2026, пишите.

Переход едет между двумя значениями, у которых есть середина. У 0 и 40px она есть, у 0 и auto — нет: auto не длина, а просьба посчитать высоту по содержимому, как это делает нормальный поток. Самая частая жертва — раскрывающаяся панель: закрыта — height: 0, открыта — height: auto. Правило написано, переход объявлен, а панель всё равно прыгает, и кажется, что CSS не работает.

Ниже две панели с одинаковым абзацем высотой 40px. overflow: hidden прячет всё, что не влезло в высоту панели, поэтому закрытая панель пуста. Первая раскрывается до auto, вторая, с классом fixed, — до числа 40px; переход у обеих одинаковый, со стоп-кадром на середине. Предскажите вторую строку.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Панель с auto сразу на полных 40px: перехода не было, значение прыгнуло. Панель с числом — на середине, 20px. Число работает, пока содержимое в него влезает: абзац выше 40px такая панель обрежет.

Научить переход ехать к auto умеют свойство interpolate-size и функция calc-size(). Но это Baseline limited: только браузеры на Chromium, начиная с версии 129. В Firefox и Safari панель по-прежнему прыгнет, так что строить на них раскрытие рано.

С display ещё строже: промежуточных значений у него нет вовсе, элемент либо участвует в раскладке, либо нет. display: none гасит элемент в тот же миг и обрывает его переходы: прозрачность не успевает плавно дойти до нуля. Свойство transition-behavior: allow-discrete (Baseline newly с 06.08.2024) разрешает переход таким «прыгающим» свойствам, и display держится до конца пути. Но саму анимацию display умеют только Chrome 117+ и Safari 18+, это limited. В Firefox элемент гаснет сразу — что с allow-discrete, что без.

ПеременаПереходПочему
width: 0 → 200pxедетмежду двумя длинами есть середина
opacity: 0 → 1едетмежду двумя числами есть середина
height: 0 → autoпрыгаетauto — не длина; interpolate-size есть только в Chromium
display: block → noneпрыгаетсередины нет; allow-discrete для display не работает в Firefox

Двигать и увеличивать элемент удобнее не шириной и отступами, а трансформацией: она не толкает соседей и не заставляет браузер заново раскладывать страницу, поэтому идёт заметно плавнее. Долго для этого было одно свойство transform, где всё пишется одной строкой: transform: translateX(20px) scale(2) — сдвиг по горизонтали и масштаб. У одной строки есть ловушка каскада: второе правило с transform не дописывает первое, а заменяет его целиком.

Сегодня у сдвига, масштаба и поворота есть собственные свойства — translate, scale и rotate, Baseline widely с 05.02.2025. Сравним. У обеих точек ниже по три класса, и у склейки .one.big специфичность выше, чем у .one: в споре за transform побеждает она. Прибор меряет коробку уже после трансформации. Предскажите обе строки: где левый край каждой точки и какой она ширины?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Ширина у обеих 40px: масштаб 2 растянул квадрат 20px вокруг его центра. А края разные. У первой точки transform: scale(2) затёр сдвиг, и остался только масштаб: коробка от -10 до 30, левый край на -10px, за границей сцены. У второй translate и scale — разные свойства, каскад их не сталкивает, и они складываются: масштаб 2 даёт коробку от -10 до 30, сдвиг +20 — от 10 до 50, край = -10 + 20 = 10.

Для переходов это не мелочь. Если «было» — сдвиг, а «стало» — масштаб в той же строке transform, переход поедет от «сдвинута» к «увеличена и не сдвинута», и точка по пути поплывёт назад. С отдельными свойствами у каждого свой путь: сдвиг стоит на месте, растёт только масштаб, а запись transition: scale 200ms, translate 1s ещё и даёт им разное время.

transform — одна строка на всё: новое правило не дописывает её, а заменяет целиком. translate, scale и rotate — три отдельных свойства, и в каскаде они друг друга не затирают.

Уведомление «Сохранено» хочется проявить плавно: вставили на страницу — прозрачное, через секунду видно. Но переход едет от «было» к «стало», а у элемента, которого только что не было, «было» нет. Это та же ловушка, что со стоп-кадром без первого замера: браузер впервые считает стиль сразу итоговым, и переходить не из чего.

@starting-style задаёт это «было» явно. Внутри его фигурных скобок — обычные правила, но действуют они только на первый стиль элемента, и с них начинается переход. Ниже два одинаковых уведомления, стартовый стиль есть только у первого, с классом soft. Прибор меряет прозрачность сразу после вставки. Предскажите строку.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
У первого 0: переход от стартового стиля только начался, и в рамке уведомление проявляется за секунду. У второго сразу 1: переход объявлен, но ехать не из чего. @starting-style — Baseline newly с 06.08.2024: знайте о нём, но не делайте его условием работы страницы. Браузер, который его не знает, пропустит правило, и уведомление просто появится без плавности.

Новым для перехода браузер считает и элемент, который показали после display: none: пока элемент скрыт, «было» у него нет. Плавный показ даёт тот же @starting-style, а allow-discrete для появления не нужен — он держит display только на пути к none.

Проявляться при открытии умеет и раскрывающийся details. Щелчок по summary ставит ему атрибут open, а всё содержимое, кроме summary, браузер собирает в псевдоэлемент ::details-content. Два двоеточия отличают псевдоэлемент от псевдокласса: это не состояние, а часть элемента, у которой нет своего тега в разметке. Правил два: содержимое прозрачное и с переходом, у открытого — видимое. Предскажите обе строки: сколько открытых details найдёт прибор до щелчка и после?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
0 и 1: щелчок поставил атрибут open, и включилось правило details[open]::details-content. Само проявление прибор не меряет — это ручное упражнение. Щёлкните «Подробнее» в рамке, чтобы закрыть, и ещё раз, чтобы открыть. В Chrome текст проявляется за секунду при каждом открытии. Firefox псевдоэлемент знает, но переход по нему не ведёт: содержимое появляется скачком. Браузер, который ::details-content не знает, пропустит оба правила, и details просто откроется без плавности.

Проявлять вместо этого сам абзац правилами для details p ненадёжно: в Chrome такой абзац не проявляется вовсе, а в Firefox — не при каждом открытии. ::details-content — Baseline newly с 16.09.2025, и плавную высоту раскрытия ему снова даёт только limited interpolate-size.

Без скрипта открывается и поповер — всплывающий элемент поверх страницы (Baseline newly с 27.01.2025). Элемент с атрибутом popover спрятан, пока его не откроет кнопка, у которой в атрибуте popovertarget записан его id. Предскажите обе строки: какой display у подсказки до нажатия и после?

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
none, затем block: закрытый поповер браузер прячет через display: none, поэтому появление устроено как у уведомления — переход плюс @starting-style. Нажмите в рамке кнопку, чтобы закрыть подсказку, и ещё раз: она проявится за секунду и в Chrome, и в Firefox. А закрывается она скачком: плавное исчезновение поповера или диалога требует ещё свойства overlay — это только Chromium 117+, в Firefox и Safari не сработает. Сам поповер — newly: знайте о нём, но не прячьте в него единственный путь к важному. Браузер, который атрибут popover не знает, покажет подсказку сразу, а кнопка ничего не сделает.

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

Два соседних механизма пока есть не везде. Плавная смена страницы при переходе по ссылке, @view-transition, работает в Chrome и Safari, но не в Firefox. Анимация, привязанная к прокрутке, — тоже в Chrome и Safari, а в Firefox нет. Оба пробела браузеры взялись закрыть по плану Interop 2026, так что статус стоит проверить позже.

Когда движением нужно управлять — остановить на полпути, развернуть, дождаться конца, — это делают из JavaScript через Web Animations API, а смену вида внутри одной страницы запускает document.startViewTransition. Это уже не пролог.

И если впереди у вас мобильное приложение: в React Native строка transition в стиле не работает. В предпросмотре в браузере полоса едет, а на телефоне молча прыгает, поэтому движение там собирают из значения, подписки и кадра.

  • transition рисует промежуточные кадры между двумя состояниями: вы задаёте свойство, длительность, плавность и задержку. Объявленный в обычном правиле элемента, он работает в обе стороны.
  • Движение проверяют стоп-кадром: отрицательная задержка при linear сразу ставит переход на известную долю пути. Число посреди настоящего движения зависит от кадров вкладки.
  • ease, плавность по умолчанию, к половине времени проходит около четырёх пятых пути, linear — ровно половину. linear() с точками рисует пружину и уже Baseline widely.
  • height: auto и display не едут — значение прыгает. interpolate-size, calc-size() и анимация display — limited и в Firefox не сработают.
  • transform — одна строка: победившее в каскаде правило заменяет её целиком, а переход сводит на нет всё, что из неё пропало. Отдельные translate, scale и rotate складываются и едут каждое своим путём.
  • У нового элемента и у показанного после display: none нет «было» — его задаёт @starting-style (newly). Содержимое details проявляют через ::details-content (newly), а браузер без его поддержки просто откроет details без плавности.

1/4Ширина меняется с 0 на 200px, переход — transition: width 400ms linear -100ms. Что покажет замер сразу после смены состояния?

2/4Две полосы едут 300ms от 0 до 200px, стоп-кадр на половине времени. У одной плавность linear, у другой ease. Где полоса с ease?

3/4Панель раскрывается от height: 0 до height: auto, у неё есть transition: height 300ms. Что увидит человек в Firefox?

4/4На страницу вставили элемент с opacity: 1 и transition: opacity 1s, без @starting-style. Как он появится?

Стоп-кадр на трети пути

Полоса растёт от 0 до 240px за 300ms с плавностью linear. Сейчас второй замер показывает 0px: переход только начался. Поменяйте в записи transition одно значение так, чтобы второй замер показал 80px.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

80px из 240 — треть пути, а с linear треть пути приходится на треть времени: 100 мс из 300. Значит, переход должен стартовать так, будто 100 мс уже прошли, — нужна задержка -100ms:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вторая строка станет .bar → width: 80px. С ease вместо linear так посчитать не выйдет: у неё доля пути не равна доле времени.

Масштаб, который не сбивает сдвиг

Карточка сдвинута на 30px вправо, её центр — на 50px от края сцены. Когда флажок отмечен, карточка должна вырасти в полтора раза, не сходя с места. Сейчас на стоп-кадре левый край уезжает с 30px на 10px: переход заодно убирает сдвиг. Перепишите стили так, чтобы переход трогал только масштаб.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сдвиг и масштаб надо развести по отдельным свойствам: тогда «стало» меняет только scale, и переход нужен только ему.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй замер покажет .card → x: 25px. На середине пути масштаб 1.25, карточка шириной 50px, а центр остался на 50px: левый край 50 - 25 = 25. В исходном варианте transform: scale(1.5) заменял всю строку, переход вёл сдвиг от 30px к нулю, и карточка уплывала влево.

Показ после display: none

Уведомление спрятано через display: none и показывается, когда флажок отмечен. Переход объявлен, замер до щелчка прочитал прозрачность, но стоп-кадр после щелчка всё равно показывает 1: плавного появления нет. Добавьте одно правило, чтобы второй замер показал 0.5.

js
разметка страницы
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый замер не помог: пока элемент скрыт через display: none, «было» для перехода у него не появляется, и при показе он для браузера новый. Стартовый стиль задаёт @starting-style:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Переход поедет от 0 к 1, отрицательная задержка поставит его на середину, и вторая строка станет .toast → opacity: 0.5. allow-discrete не нужен: он держит display до конца пути при исчезновении, а при появлении элемент виден с первого кадра. Написать opacity: 0 в само правило .toast нельзя — это уже «стало», и уведомление останется прозрачным.