Каскадные анимации без вычислений в nth-child
Замените цепочки nth-child и циклы JavaScript на sibling-index() для каскадных CSS-анимаций. Разберите варианты, применение в вёрстке, поддержку и запасной вариант.
Одно правило, animation-delay: calc((sibling-index() - 1) * 80ms), создаёт каскадную анимацию (stagger) для списка любой длины. Вам больше не нужны отдельное правило :nth-child() для каждого элемента или цикл на JavaScript, задающий --i.
Большая часть кода для каскадных анимаций пишется под пять элементов. Затем кто-то добавляет шестую карточку, и она появляется одновременно с первой.
В этой статье мы избавимся от такого кода. На протяжении всей статьи используется один пример: список карточек, каждая из которых появляется чуть позже предыдущей. Мы рассмотрим старый подход и его замену, обратный вариант и вариант «от центра», применение sibling-count() в вёрстке, а также защитное условие, с которым этот код можно выпускать в продакшен. Речь пойдёт только о CSS. Подход для React описан в статье staggered text animations with Framer Motion.
Ключевые выводы
sibling-index()возвращает позицию элемента среди соседних элементов, начиная с 1.sibling-count()возвращает количество дочерних элементов родителя, включая сам элемент.- Обе функции возвращают обычные числа. Временем или длиной они становятся, только если умножить их на единицу измерения внутри
calc(). animation-delay: calc((sibling-index() - 1) * 80ms)заменяет отдельное правило:nth-child()для каждой карточки, какой бы длины ни был список.- Обе функции появились в Chrome и Edge 138 в июне 2025 года, в Safari 26.2 в декабре 2025 года и в Firefox 154 в августе 2026 года. Это означает статус Baseline newly available.
- Поместите задержку внутрь
@supports (top: calc(1px * sibling-index())). Тогда браузеры без поддержки покажут все карточки одновременно, и вёрстка не сломается.
Старый подход: лесенки nth-child и встроенные пользовательские свойства
До появления sibling-index() каскадная анимация на CSS означала, что задержку для каждой позиции приходилось прописывать вручную. Для этого было два способа: лесенка из :nth-child() или пользовательское свойство с индексом, записанное прямо в разметку. Лесенка использует :nth-child(), который, как и знакомые большинству разработчиков селекторы соседних элементов CSS, выбирает элементы по их положению среди соседей:
.card { animation: fade-in 400ms ease both; }
.card:nth-child(2) { animation-delay: 80ms; }
.card:nth-child(3) { animation-delay: 160ms; }
.card:nth-child(4) { animation-delay: 240ms; }
.card:nth-child(5) { animation-delay: 320ms; }
Лесенка заканчивается на той длине, под которую её написали. Вариант с пользовательским свойством переносит подсчёт в HTML:
<li class="card" style="--i: 0">…</li>
<li class="card" style="--i: 1">…</li>
<li class="card" style="--i: 2">…</li>
<li class="card" style="--i: 3">…</li>
.card { animation-delay: calc(var(--i) * 80ms); }
В случае динамических списков индекс обычно приходится задавать скриптом:
document.querySelectorAll('.card').forEach((el, i) => el.style.setProperty('--i', i));
Как sibling-index() заменяет лесенку nth-child?
sibling-index() заменяет всю лесенку :nth-child() одним объявлением: animation-delay: calc((sibling-index() - 1) * 80ms). Функция определена в разделе tree-counting functions спецификации CSS Values and Units Level 5. sibling-index() присваивает первому соседнему элементу номер 1, как и :nth-child(), и при подсчёте пропускает текстовые узлы и комментарии.
@keyframes fade-in {
from { opacity: 0; translate: 0 8px; }
}
.card {
animation: fade-in 400ms ease both;
animation-delay: calc((sibling-index() - 1) * 80ms);
}
Функция возвращает целое число без единиц измерения, поэтому именно умножение внутри calc() превращает его во время.
Единица вычитается, чтобы у первой карточки не было задержки. При пяти карточках индексы от 1 до 5 превращаются в 0, 80, 160, 240 и 320ms. Без - 1 каждая карточка ждёт на один шаг дольше, и список 80ms остаётся неподвижным, прежде чем что-либо произойдёт.
Ключевое слово both в сокращённой записи тоже важно. Когда animation-fill-mode имеет значение both или backwards, первый ключевой кадр применяется на время задержки, поэтому ожидающие карточки остаются невидимыми. Без него последующие карточки отображаются с полной непрозрачностью, а затем резко исчезают, когда начинается их анимация.
Для длинных списков задержку можно ограничить, чтобы 40-я карточка не ждала больше трёх секунд:
.card { animation-delay: calc(min(sibling-index() - 1, 10) * 80ms); }
Как запустить каскад в обратном порядке или от центра?
Чтобы обратить каскад на основе sibling-index(), вычтите индекс из sibling-count(). Выражение calc((sibling-count() - sibling-index()) * 80ms) даёт последней карточке задержку 0ms, поэтому последняя карточка анимируется первой, а первая — последней. Для каскада от центра вычислите расстояние каждой карточки до середины:
.card { --centre: calc((sibling-count() + 1) / 2); }
.list--reverse .card {
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}
.list--centre .card {
animation-delay: calc(
max(sibling-index() - var(--centre), var(--centre) - sibling-index()) * 80ms
);
}
При пяти карточках центр равен 3, поэтому задержки составляют 160, 80, 0, 80 и 160ms. При чётном количестве центр оказывается между двумя карточками. Четыре карточки дают 120, 40, 40 и 120ms, и две средние карточки стартуют одновременно.
Использование sibling-count() в вёрстке
sibling-count() также можно использовать как значение для вёрстки. Функция считает все дочерние элементы родителя, включая сам элемент, как описано в справочнике MDN. Деление на это значение даёт равные доли: width: calc(100% / sibling-count()). Вот как это выглядит на том же списке карточек с учётом отступа между ними:
.list { display: flex; --gap: 1rem; gap: var(--gap); }
.card {
width: calc((100% - (sibling-count() - 1) * var(--gap)) / sibling-count());
--progress: calc(sibling-index() / sibling-count() * 100%);
}
.card::after { content: ""; display: block; height: 3px; width: var(--progress); }
В обычной flex-строке равную ширину и так даёт flex: 1. Формула полезна там, где flexbox не справляется: для абсолютно позиционированных или перекрывающихся карточек, а также когда ширина используется в других вычислениях. --progress задаёт каждой карточке её долю от общего количества, поэтому карточка 3 из 5 рисует полосу шириной 60%.
sibling-count() и sibling-index() считают соседей самого элемента, а не его дочерние элементы. Если применить sibling-count() к <ul>, вы получите количество дочерних элементов родителя списка (включая сам список), а не количество элементов <li> в нём. Вычисления, зависящие от числа дочерних элементов, размещайте в правилах для самих дочерних элементов.
Выпуск в продакшен: поддержка браузерами и защита через @supports
sibling-index() и sibling-count() поддерживаются всеми тремя основными движками, поэтому имеют статус Baseline newly available.
| Движок | Версия | Дата выхода |
|---|---|---|
| Chrome / Edge | 138 | Июнь 2025 |
| Safari (macOS, iOS) | 26.2 | Декабрь 2025 |
| Firefox | 154 | Август 2026 |
Пользователи старых версий всё ещё существуют, поэтому оставьте саму анимацию появления вне защитного блока, а внутрь поместите только каскадную задержку:
.card { animation: fade-in 400ms ease both; }
@supports (top: calc(1px * sibling-index())) {
.card { animation-delay: calc((sibling-index() - 1) * 80ms); }
}
Проверка оборачивает функцию в calc(), результатом которого является длина, и применяет его к свойству, принимающему длины. Браузер, который не может разобрать такое объявление, считает условие ложным и пропускает блок. Все карточки по-прежнему появляются, просто одновременно.
Учёт prefers-reduced-motion
При prefers-reduced-motion: reduce установка animation: none для карточек сразу отображает их все в конечном состоянии:
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}
Заключение
Теперь каскадная анимация умещается в одно защищённое объявление, которое работает с любым количеством карточек. Удалите лесенку :nth-child() и скрипт, записывающий --i, добавьте блок @supports и правило для reduced motion — и в разметке больше не понадобятся атрибуты с индексами. Если значительная часть ваших анимаций всё ещё зависит от JavaScript, прочитайте статью replacing animation libraries with native web APIs.
Часто задаваемые вопросы
Чем sibling-index() отличается от CSS-функции counter()?
counter() возвращает текст, поэтому пригодна только внутри свойства content. sibling-index() возвращает число, с которым можно выполнять вычисления, например calc((sibling-index() - 1) * 80ms). Значение счётчика нельзя использовать для animation-delay, ширины или угла. sibling-index() можно, и для неё не нужны правила counter-reset или counter-increment.
Может ли sibling-index() пропускать определённые элементы, как это делает :nth-child(An+B of S)?
Нет. sibling-index() и sibling-count() не принимают аргументов, поэтому отфильтровать их по селектору нельзя. Они считают все соседние элементы у одного родителя независимо от класса. Заголовок, разделитель или элемент-обёртка внутри списка сдвигает индекс всех последующих карточек и увеличивает общее количество. Анимируемые элементы должны быть единственными дочерними элементами своего контейнера.
Можно ли с помощью sibling-index() управлять цветом и поворотом, а не только задержками?
Да. Это целое число работает в любом выражении calc(), поэтому умножение на единицу измерения даёт угол или длину, например rotate: calc(sibling-index() * 15deg). Деление на sibling-count() равномерно распределяет значение по списку. Например, calc(sibling-index() / sibling-count() * 360deg) задаёт каждому элементу собственный оттенок или собственную позицию на окружности.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k