Команда JavaScript for Devs подготовила перевод статьи о новом CSS-правиле @starting-style
— инструменте, который обещает упростить анимацию появления элементов. Но всё ли так гладко? Автор показывает, что за красивым синтаксисом скрываются подводные камни специфичности и неожиданные баги, из-за которых старые добрые keyframes по-прежнему оказываются надёжнее.
Слышали про правило @starting-style
? Это интересный новый инструмент, который позволяет использовать CSS-переходы для анимации появления элементов.
Представим, что у нас есть интерфейс, где элементы динамически добавляются на страницу, и мы хотим, чтобы они плавно проявлялись.
Когда вы нажимаете кнопку «Add Element», создаётся новый фиолетовый квадрат, который добавляется на страницу и с помощью CSS-анимации постепенно появляется за 1 секунду.
Исторически основное ограничение CSS-переходов в том, что они срабатывают только тогда, когда целевое CSS-свойство изменяет своё значение. Если мы хотим, чтобы свойство анимировалось в момент создания элемента, нам приходилось использовать CSS-анимации с ключевыми кадрами — как в примере выше.
Новый API @starting-style
предлагает воркэраунд для решения этой проблемы. Мы можем задать альтернативный набор CSS-деклараций, из которых элемент будет «переходить» в свои финальные стили сразу после создания.
Посмотрите, как это работает:
Поддержка в браузерах: если у вас не воспроизводится та же анимация появления во втором примере, скорее всего, ваш браузер ещё не поддерживает этот новый API. На момент написания статьи (сентябрь 2025 года) поддержка
@starting-style
составляет примерно 86% браузеров (источник — Can I use).
Каждый элемент .box
инициализируется со свойством opacity: 0
, заданным внутри блока @starting-style
. Сразу после создания элемента это свойство удаляется, и срабатывает CSS-переход к opacity: 1
, который прописан в основных стилях.
Звучит круто, но есть подвох. CSS внутри @starting-style
обрабатывается браузером не так, как CSS в @keyframes
, и это может привести к проблемам. ?
В этой статье мы разберём найденную мной особенность этого API и посмотрим, какие есть обходные пути. По дороге мы также углубимся в тему специфичности CSS, так что даже если вас не особо интересует @starting-style
, уверен — материал всё равно будет полезен!
Для кого эта статья: она рассчитана на тех, кто знаком с основами CSS. Если вы поняли код из примеров выше, дальше вы точно не потеряетесь.
Проблема специфичности
Если вы уже какое-то время работаете с CSS, то наверняка знакомы с понятием специфичности. Когда разные CSS-правила конфликтуют между собой, браузер использует определённую систему приоритетов, чтобы решить, какое именно правило применить.
Рассмотрим пример:
<button class="primary-button">
Hello World
</button>
<style>
button {
background-color: transparent;
}
.primary-button {
background-color: blue;
}
</style>
Здесь у нас есть два CSS-правила, и оба подходят под элемент <button>
. Каждое задаёт разный цвет фона. Кнопка не может быть одновременно и прозрачной, и синей. Как браузер решает, какое свойство использовать?
Согласно правилам специфичности, селекторы классов (например, .primary-button
) имеют более высокий приоритет, чем селекторы тегов (например, button
). Значит, побеждает правило с классом — и наша кнопка будет синей.
Кроме иерархии специфичности (тег → класс → id), в CSS есть ещё и уровни приоритета между различными группами стилей. Формально это отдельное понятие, но по сути оно ощущается как более «высокий уровень» того же принципа.
Например, каждый браузер имеет встроенный набор CSS-стилей — так называемые user-agent styles. Именно поэтому заголовки по умолчанию выделены жирным, а элемент <blockquote>
выглядит иначе, чем <p>
. Вместо того чтобы считать специфичность для каждого встроенного правила, браузер просто рассматривает их как отдельный слой CSS. Они применяются первыми, а любой написанный нами CSS, независимо от его специфичности, сможет их переопределить.
Другой пример — флаг !important
. Любое правило с ним перемещается в отдельную высокоприоритетную группу стилей и автоматически побеждает любое другое правило без !important
, вне зависимости от специфичности.
А как насчёт анимаций с ключевыми кадрами? Они тоже образуют отдельную категорию CSS-правил. Именно поэтому мы можем делать вот так:
<style>
@keyframes fadeFromTransparent {
from {
opacity: 0;
}
}
h1 {
animation: fadeFromTransparent 1000ms;
}
#title {
opacity: 1;
}
</style>
<h1 id="title"></h1>
Интересный момент, если задуматься. Наша анимация fadeFromTransparent
изменяет свойство opacity
, и делаем мы это внутри селектора тега (h1
). При этом в ID-селекторе (#title
) мы явно задаём opacity: 1
. По правилам специфичности именно это свойство должно бы «победить» и перекрыть анимацию появления!
Однако этого не происходит. Почему? Потому что CSS-декларации внутри @keyframes
поднимаются в отдельный слой стилей. Этот слой имеет второй по приоритету уровень — сразу после стилей с !important
. Благодаря этому наши анимации с ключевыми кадрами почти всегда работают как задумано: нам не нужно беспокоиться о специфичности.
Но с @starting-style
всё иначе. В отличие от анимаций с @keyframes
, стили внутри блока @starting-style
не повышаются в приоритете. Для них действуют обычные правила специфичности.
В результате анимация появления не сработает в таких случаях:
Когда этот заголовок создаётся, браузер выполняет расчёт специфичности. Так как #title
имеет более высокий приоритет, чем h1
, элемент инициализируется с opacity: 1
, а не 0
.
CSS-переходы срабатывают только тогда, когда применяемые стили изменяются. В нашем случае значение opacity
никогда фактически не становится равным 0
, поэтому переход не запускается. Заголовок сразу отображается с полной непрозрачностью.
Признаюсь, пример немного надуманный, и большинство современных подходов к написанию CSS (Tailwind, styled-components, CSS Modules, BEM и т. д.) защитят вас от подобных проблем со специфичностью. Но даже если вы используете одну из этих методологий — эта ловушка всё равно может вас подстеречь.
Давайте рассмотрим реальный пример, с которым я недавно столкнулся. В моём грядущем курсе по созданию «игривых» анимаций мы собираем вот такой эффект с частицами:

Когда пользователь нажимает кнопку «Like», генерируется 15–20 частиц. Все они стартуют точно из центра кнопки, а затем разлетаются наружу в случайном направлении на случайное расстояние. Это движение задаётся с помощью CSS-трансформаций. Например, частица может перейти от transform: translate(0px, 0px)
(строго по центру) к transform: translate(42px, -55px)
(вверх и вправо).
Когда я попытался использовать @starting-style
для этого, ничего не заработало. Пару минут я сидел в полном недоумении.
Этот эффект частиц обманчиво сложен, и целиком его реализация слишком объёмна для одного поста, но я постарался выделить самую суть, чтобы мы могли увидеть проблему в действии:
Попробуйте нажать на кнопку и обратите внимание: частицы не «выстреливают». Они мгновенно оказываются в конечной позиции, вместо того чтобы анимироваться из начального положения. Ожидаемое поведение должно быть таким:

Если заглянуть в код, видно, что начальную позицию мы задаём в CSS через @starting-style
. Конечная позиция генерируется динамически для каждой частицы и устанавливается в /index.js
:
particle.style.transform = `translate(
calc(cos(${angle}deg) * ${distance}px),
calc(sin(${angle}deg) * ${distance}px)
)`;
Проблема снова упирается в специфичность. Когда мы устанавливаем стиль из JavaScript вот таким образом, он применяется как встроенный стиль (inline style), который гораздо специфичнее, чем начальное положение, заданное в CSS-классе (.particle
). В результате стартовые стили фактически никогда не применяются к частицам.
Это реальный пример той самой тонкой проблемы, с которой я столкнулся, когда пытался использовать @starting-style
в своей работе. Я считаю, что неплохо ориентируюсь в вопросах специфичности, но даже так такие вещи легко выбивают из колеи!
Подождите, тригонометрия? Что?
В примере выше я делаю одну любопытную вещь: вместо того чтобы сразу генерировать случайные значения x/y
, я для каждой частицы выбираю случайные угол и дистанцию, а затем использую тригонометрические функции CSS (cos()
и sin()
), чтобы преобразовать их в декартовы координаты.
Это может показаться лишним усложнением, но так получается куда более приятный рисунок рассеивающихся частиц. Разница особенно заметна, если сильно увеличить их количество:

Решения
Итак, вот в чём проблема. Спасибо, что дочитали до конца это длинное объяснение)
Теперь поговорим о том, как можно её решить.
1. Радикальный вариант
Один из способов устранить проблему — повысить приоритет декларации @starting-style
, добавив !important
:
.particle {
transition: transform 500ms;
@starting-style {
transform: translate(0px, 0px) !important;
}
}
Как мы уже видели ранее, !important
поднимает CSS-правило в самую верхнюю приоритетную группу, игнорируя все вычисления специфичности.
Это отлично работает, но каждый раз, когда я использую !important
, у меня ощущение, будто я заключаю сделку с дьяволом. Проблему он решает мгновенно, но ценой возможных сложностей с поддержкой в будущем. Мы сокращаем число инструментов, которые сможем использовать позже, если столкнёмся с другими конфликтами специфичности.
Хотя в данном случае всё не так уж страшно — стартовые стили автоматически удаляются сразу после создания элемента — всё равно это не выглядит идеальным решением.
2. CSS-переменные
Более изящное решение этой проблемы — изменить способ применения финального свойства transform
, используя кастомные CSS-переменные:
/* /styles.css */
.particle {
transform: translate(var(--x), var(--y));
transition: transform 500ms;
@starting-style {
transform: translate(0px, 0px);
}
}
/* /index.js */
const angle = random(0, 360);
const distance = random(32, 64);
particle.style.setProperty(
'--x',
`calc(cos(${angle}deg) * ${distance}px)`
);
particle.style.setProperty(
'--y',
`calc(sin(${angle}deg) * ${distance}px)`
);
В JavaScript-файле мы создаём две новые CSS-переменные (--x
и --y
). Затем можем использовать их значения в стилях класса .particle
.
В итоге обе декларации transform
имеют одинаковую специфичность, и поскольку @starting-style
находится ниже финального transform
, всё работает именно так, как ожидается.
На мой взгляд, это очень элегантное решение — оно аккуратно обходит проблему специфичности.
Но я не хочу, чтобы мой код был элегантным и утончённым! Я хочу, чтобы он был максимально простым и понятным — таким, чтобы даже самый неопытный член команды мог легко разобраться, и чтобы мне самому не пришлось ломать голову, возвращаясь к нему через пару месяцев.
3. Вместо этого — keyframes
Вместо того чтобы прибегать к !important
или хитроумным решениям с CSS-переменными, мы можем просто вернуться к проверенной классике — анимациям через keyframes!
Посмотрите-ка! Этот подход прекрасно работает. Он требует всего пары строк простейшего CSS и при этом остаётся крайне удобным в поддержке.
К тому же, он совместим практически со всеми браузерами. Да, @starting-style
уже поддерживается довольно неплохо, но пройдёт ещё много лет, прежде чем он станет таким же универсальным, как анимации на keyframes.
Мне кажется, главная привлекательность @starting-style
в том, что разработчики уже отлично знакомы с переходами (transition
), а keyframes кажутся менее гибкими и интуитивными.
Но, если честно, в современном CSS анимации на keyframes ничуть не уступают переходам — а порой даже превосходят их по возможностям! На мой взгляд, анимации через keyframes — это по-настоящему недооценённый инструмент.
Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
Синтаксический сахар?
Мне кажется, что @starting-style
сам по себе не открывает новых возможностей с точки зрения типов анимаций в вебе. Во всех примерах, что я видел, то же самое можно сделать с помощью анимаций на CSS-keyframes. И, как мы убедились в этой статье, с keyframes порой даже проще!
Из-за этого у меня закрадывается мысль, что я что-то упускаю. Во многих примерах в сети @starting-style
сочетают с другими современными возможностями CSS, вроде transition-behavior: allow-discrete
и interpolate-size: allow-keywords
. Но, насколько я понимаю, всё это работает не хуже и с анимациями на keyframes.
Если вы знаете пример, который можно реализовать только с помощью @starting-style
, пожалуйста, дайте знать!
И в любом случае — большое спасибо всем, кто причастен к появлению этой и множества других современных возможностей CSS. Темпы развития в последние годы просто нереальные, и я могу лишь представить, сколько труда за этим стоит. Я очень благодарен за все новые инструменты, которые вы нам подарили.
Обновление: найдено новое преимущество
Несколько читателей поделились весомым отличием между анимациями на keyframes и
@starting-style
: прерывания.Представим, что у
<dialog>
есть входная и выходная анимации — плавное появление и исчезновение. Если пользователь очень быстро открывает и закрывает модалку, входная анимация не успеет завершиться. С keyframes это приведёт к мерцанию, тогда как подход с@starting-style
корректно обработает прерывание и начнёт исчезать из «полупрозрачного» промежуточного состояния.Один из читателей, Niki, любезно поделился CodePen, демонстрирующим эту разницу.