Команда 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, демонстрирующим эту разницу.

Комментарии (0)