はじめに:「なぜ今までできなかったんだ」という感覚

カードグリッドに1つずつフェードインするエフェクト——見た目は素晴らしいですが、実装するたびに「もっとスマートな方法があるはず」と感じていた方も多いのではないでしょうか。10個のアイテムに階段状の遅延アニメーション(staggered animation)を適用するには、従来は2つの選択肢しかありませんでした。

選択肢1: nth-child のハードコーディング

/* アイテムごとに1ルール。リストが増えないことを祈る */
li:nth-child(1) { --idx: 1; }
li:nth-child(2) { --idx: 2; }
li:nth-child(3) { --idx: 3; }
/* ... あと7つ ... */
li {
  animation-delay: calc(var(--idx) * 100ms);
}

10個なら10行、50個なら? Sassループでビルド時に50ルールを生成するか、「10個でいいや」と妥協するしかありませんでした。

選択肢2: JavaScriptでインラインスタイル注入

items.forEach((el, i) => el.style.setProperty('--index', i));

正常に動作しますが、6ヶ月後に誰かがコンポーネントをリファクタリングする際、CSSがJavaScriptの変数に依存していることを忘れてしまい、静かに壊れます。

両方のアプローチに共通する根本的な問題:ブラウザはすでにDOMツリーを把握しているのに、CSSがその情報にアクセスできなかったため、迂回していたのです。

そして、その壁がついに取り払われました。

li {
  animation-delay: calc(sibling-index() * 100ms);
}

たった1行。5個でも5000個でも動作します。イベントリスナーも、MutationObserverも、再レンダリングも不要です。

CSS code snippet showing sibling-index and sibling-count functions in a code editor Development Concept Image

sibling-index()sibling-count() 完全理解

基本概念

これらの関数はCSS Values and Units Module Level 5仕様(Section 9)の一部です。CSSWG issue #4559で長い議論を経て承認されました。

  • sibling-index(): 親の子要素における現在の要素の1ベースの位置を返します。最初の子は 1、5番目は 5。テキストノードやコメントは無視され、要素ノードのみがカウントされます。
  • sibling-count(): 親が持つ子要素の総数を返します。JavaScriptの element.parentElement.children.length と同等です。

両関数とも <number> 型として解決されるため、calc()min()max()round()sin()cos() など、あらゆる計算コンテキストで即座に使用できます。

⚠️ 混同注意: :nth-child()セレクターです。要素を選択するだけで値を返しません。calc(:nth-child() * 10px) は無効なCSSです。一方、sibling-index()値生成関数であり、宣言部の中で数値を提供します。

実践パターン6選

1. 階段アニメーション (Staggered Fade-in)

.card {
  animation: fadeIn 0.4s ease both;
  animation-delay: calc(sibling-index() * 80ms);
}

2. 逆方向階段アニメーション (Reverse Stagger) - 最後のアイテムから

.card {
  animation: fadeIn 0.4s ease both;
  animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}

最後のカードが最初に表示され、最初のカードが最も遅く表示されます。

3. 自動均等幅 (Equal Widths)

.tab {
  width: calc(100% / sibling-count());
}

タブが5つなら20%、6つなら16.66%、4つなら25%。メディアクエリもJSも不要で自動調整されます。

4. 色相の均等分布 (Hue Distribution)

.swatch {
  background-color: hsl(
    calc((360deg / sibling-count()) * sibling-index()) 70% 50%
  );
}

3アイテムなら120°間隔、12アイテムなら30°間隔。DOMに応じてパレットが自動適応します。

5. 円形メニュー (Circular Menu) - ピュアCSSで三角関数まで

.radial-item {
  --angle: calc((360deg / sibling-count()) * sibling-index());
  --radius: 120px;
  position: absolute;
  left: calc(50% + var(--radius) * cos(var(--angle)));
  top: calc(50% + var(--radius) * sin(var(--angle)));
  transform: rotate(calc(var(--angle) * -1));
}

6アイテムなら六角形、8アイテムなら八角形。追加・削除に応じてレイアウトが自動再計算されます。

6. Z-Index スタッキング (Card Fan)

.card {
  z-index: calc(sibling-count() - sibling-index());
}

最初のカードが最上位、最後のカードが 0。順序を逆にしたい場合は sibling-index() のみを使用します。

Developer testing staggered animation delay using pure CSS on a grid of cards Technical Structure Concept

注意点と落とし穴(これを知らないとデバッグ地獄)

Shadow DOM スコーピング

sibling-index()sibling-count()DOMツリーを基準に動作し、フラットなビジュアルツリーは参照しません。Web Components ではこの違いが決定的です。

<!-- カスタム要素のShadow DOM -->
<host-element>
  #shadow-root
    <style></style>
    <div class="internal"></div>

.internalsibling-index() を適用すると、常に 2 を返します。<style>.internal の2つの子しかないからです。プロジェクトに300個の <project-item> がlight DOMに投影されていても、shadowツリー内では sibling-count()2 です。

また、::part() を通じて外部スタイルシートがコンポーネント内部を探索しようとすると、sibling-index()0 を返します。これは意図的なセキュリティ障壁です。

display: none もカウントされる

これが最も狡猾な落とし穴です。display: none はレイアウトツリーから消えますが、DOMツリーには残ります。sibling-index() はDOMツリーを読むため、非表示要素もインデックスに含まれます。

<ul>
  <li>Apple</li>
  <li style="display: none">Banana</li>  <!-- 見えないがindexは2 -->
  <li>Cherry</li>  <!-- indexは3、2ではない -->
</ul>

検索フィルターで display: none を使用している場合、階段アニメーションや円形メニューに**隙間(gap)**が生じます。解決するには、フィルタリングされたノードを実際にDOMから削除するか、JavaScriptでインデックスを管理する必要があります。

カスタムプロパティの即時評価問題

/* 誤った例 */
.parent {
  --idx: sibling-index();  /* .parent自身の兄弟インデックスで固定 */
}
.child {
  animation-delay: calc(var(--idx) * 100ms);  /* すべての子が同じ値を継承 */
}

解決策は簡単です。関数を適用対象の要素に直接記述してください。

.child {
  --idx: sibling-index();
  animation-delay: calc(var(--idx) * 100ms);
}

パフォーマンス:大規模DOMにおけるコスト

10,000子要素の先頭に要素を挿入すると、ブラウザは残り9,999要素の兄弟インデックスをすべて再計算する必要があります。通常のナビゲーション、カードグリッド、タブバーでは全く問題になりませんが、リアルタイム株価ティッカーや無限スクロールフィードのように数千ノードが絶えず変動する環境では、JavaScript管理のインデックスを維持する方が安全です。

ブラウザサポート状況

  • Chrome/Edge 138(2025年6月):安定版でサポート
  • Safari 26.2:サポート
  • Firefox:未サポート(Bugzilla #1953973で実装進行中、ポジティブ)

Chrome + Safari の組み合わせでグローバルトラフィックの約75〜80%をカバーします。Firefoxユーザー向けのフォールバックは @supports で処理してください。

/* 全ブラウザで動作するベースライン */
.item {
  width: 25%;
  animation-delay: 0ms;
}

/* サポートするブラウザでのみ数学的レイアウトを有効化 */
@supports (z-index: sibling-index()) {
  .item {
    width: calc(100% / sibling-count());
    animation-delay: calc(sibling-index() * 80ms);
  }
}

アクセシビリティ注意事項

これらの関数は純粋に視覚的です。sibling-index()order やグリッド配置を変更しても、スクリーンリーダーはDOMソース順を読み、キーボードタブ順もDOMに従います。視覚的レイアウトと意味的構造が矛盾すると、アクセシビリティ違反になります。インタラクティブコンポーネント(データグリッド、ラジアルメニューなど)では、引き続きJavaScriptで aria-posinsetaria-setsize を同期する必要があります。

Chrome DevTools inspecting computed sibling-index value for a list item

日本の開発現場での適用コンテキスト

国内ではまだ sibling-index()sibling-count() に関する情報は限られています。多くのCSS解説記事やブログでは、未だに :nth-child() セレクターとSassループを用いた階段アニメーションの手法が主流です。

実務適用のヒント:

  • ダッシュボードUI: KPIカードグリッドへの階段状出現アニメーションに真価を発揮します。データ量が動的に変化してもCSSだけで対応可能です。
  • ECサイトの商品一覧: フィルタリング結果がリアルタイムで変わる環境で、商品カードの幅を自動調整する用途に活用してください。
  • レガシープロジェクトへの導入: 段階的に導入する場合は @supports フォールバックを必ず使用しましょう。まだ全ブラウザがサポートしているわけではないため、「壊れない」プログレッシブエンハンスメントが鍵です。

この技術の限界

  • display: none フィルタリングとの相性が良くありません。検索・フィルター機能があるリストでは、JavaScriptで実際にDOMを削除する方式を検討する必要があります。
  • Shadow DOM内部では期待と異なる動作をする可能性があります。Web Componentsを使用中の場合は必ずテストしてください。
  • 10,000要素以上の大規模動的リストではパフォーマンスコストが発生する可能性があります。

次のステップとしての学習方向

  1. CSS @property の学習:カスタムプロパティの型と継承を制御する方法を習得すると、sibling-index() との組み合わせがさらに強力になります。
  2. CSS三角関数(sin()cos() のマスター:円形レイアウトだけでなく、波形アニメーションや曲線パスなど、多彩な表現が可能になります。
  3. CSSコンテナークエリとの組み合わせ:sibling-count() で取得した個数に応じてコンテナスタイルを動的に変更するパターンを研究してみてください。

合わせて読みたい記事


本記事は Smashing Magazineの原文 を基に、日本の開発者向けに再構成しました。

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。