12k
All articles

nth-childの計算なしで実現するスタッガーアニメーション

nth-childの連続ルールやJavaScriptのループをsibling-index()で置き換え、CSSアニメーションを順番に表示。逆順、中央からの演出、対応状況も解説。

OpenReplay Team
OpenReplay Team
nth-childの計算なしで実現するスタッガーアニメーション

animation-delay: calc((sibling-index() - 1) * 80ms) というルール1つで、長さを問わずリストにスタッガー(時間差)アニメーションを適用できます。アイテムごとに :nth-child() ルールを書いたり、JavaScriptのループで --i を設定したりする必要はもうありません。

スタッガーのコードは、たいてい5アイテムを前提に書かれています。そこに誰かが6枚目のカードを追加すると、6枚目は1枚目と同時にフェードインしてしまいます。

この記事では、そうしたコードを削除します。例は最初から最後まで1つだけです。カードのリストが、前のカードより少しずつ遅れてフェードインしていくものです。従来のアプローチ、その置き換え、逆順と中央から外側へのバリエーション、sibling-count() のレイアウトへの応用、そして本番環境に安全に投入するためのガードを順に紹介します。扱うのはCSSのみです。Reactでのアプローチについては、Framer Motionによるスタッガーテキストアニメーションを参照してください。

重要なポイント

  • sibling-index() は、兄弟要素の中での要素の位置を1から数えて返します。sibling-count() は、親が持つ子要素の数を返します。この数には要素自身も含まれます。
  • どちらの関数も単なる数値を返します。calc() 内で単位を掛けて初めて、時間や長さになります。
  • animation-delay: calc((sibling-index() - 1) * 80ms) は、リストがどれだけ長くなっても、カードごとの :nth-child() ルールを置き換えられます。
  • 両関数は、2025年6月にChromeとEdge 138、2025年12月にSafari 26.2、2026年8月にFirefox 154で実装されました。これによりBaseline Newly available(新たに利用可能)となっています。
  • ディレイは @supports (top: calc(1px * sibling-index())) の中に記述してください。非対応ブラウザでは表示が崩れず、すべてのカードが同時にフェードインするだけになります。

従来の方法:nth-childの階段状ルールとインラインのカスタムプロパティ

sibling-index() が登場する前は、CSSでスタッガーを実現するには位置ごとにディレイをハードコードする必要がありました。方法は2つあります。: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) という1つの宣言に置き換えられます。この関数はCSS Values and Units Level 5のツリーカウント関数で定義されています。sibling-index() は、:nth-child() と同様に最初の兄弟要素を1と数え、テキストノードやコメントノードはカウントから除外します。

@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を引くのは、最初のカードのディレイを0にするためです。カードが5枚の場合、インデックス1〜5はそれぞれ0、80、160、240、320msになります。- 1 がないと、すべてのカードが1ステップ余分に待つことになり、リストは何も起こらないまま80ms静止してしまいます。

ショートハンドの both キーワードも重要です。animation-fill-mode が both または backwards に設定されていると、ディレイ中に最初のキーフレームが適用されるため、待機中のカードは非表示のままになります。これがないと、後続のカードは不透明度100%で表示され、アニメーション開始時に突然0へ切り替わってしまいます。

長いリストでは、ディレイに上限を設けて、40枚目のカードが3秒以上待たされないようにできます。

.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
  );
}

カードが5枚の場合、中央は3なので、ディレイは160、80、0、80、160msとなります。枚数が偶数の場合、中央は2枚のカードの間に位置します。4枚なら120、40、40、120msとなり、中央の2枚が同時に開始します。

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 だけで均等な幅になります。この計算式が役立つのは、flexでは対応できない場合です。たとえば、絶対配置や重なり合うカード、あるいは幅を他の計算に利用する場合などです。--progress は各カードに全体に対する割合を与えるので、5枚中3枚目のカードには60%のバーが描画されます。

sibling-count() と sibling-index() がカウントするのは、要素自身の兄弟要素であり、子要素ではありません。<ul> に sibling-count() を指定すると、得られるのは <li> アイテムの数ではなく、リストの親が持つ子要素の数(リスト自身を含む)です。子要素の数に基づく計算は、子要素側に記述してください。

本番投入:ブラウザサポートと@supportsガード

sibling-index() と sibling-count() は主要3エンジンすべてで実装されているため、Baseline Newly availableとなっています。

エンジンバージョンリリース
Chrome / Edge1382025年6月
Safari(macOS、iOS)26.22025年12月
Firefox1542026年8月

古いバージョンを使っているユーザーもまだ存在するため、フェード自体はガードの外に置き、スタッガーだけをガードの中に入れます。

.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; }
}

まとめ

スタッガーは、カードが何枚あっても機能する、ガード付きの1つの宣言にまとまりました。:nth-child() の階段状ルールと --i を書き込むスクリプトを削除し、@supports ブロックとreduced-motionのルールを追加すれば、マークアップにインデックス属性を持たせる必要はなくなります。アニメーション処理の多くをまだJavaScriptに頼っている場合は、アニメーションライブラリをネイティブWeb APIで置き換えるを参照してください。

よくある質問

sibling-index()とCSSのcounter()関数の違いは何ですか?

counter()はテキストを生成するため、contentプロパティの中でしか使えません。sibling-index()は、calc((sibling-index() - 1) * 80ms)のように計算に使える数値を生成します。counterの値では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)とすれば、各アイテムに固有の色相や、円周上の固有の位置を割り当てられます。

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.