CSSで、中の項目の数に合わせてレイアウトを変える方法

​​CSS​CSS Grid,HTML,Playwright

リストやカードの並びを、中の項目の数で変えたいときは、親要素に :has() と :nth-child() を組み合わせたセレクターを書きます。「2番目の子がある」なら2個以上、「最後の子が3番目」ならちょうど3個、というように数を判定でき、JavaScript で数を数えてクラスを付ける必要はありません。項目をあとから足したり消したりしても、レイアウトはその場で切り替わります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。幅 600px のリスト(項目の間は 8px)を Playwright で開き、各項目の位置と幅、親の grid-template-columns の計算値を測りました。Firefox と Safari での表示は試していません。各ブラウザーの対応バージョンは、互換性データ(@mdn/browser-compat-data 8.1.8)の値です。

:has() で、親から子の数を判定する

:has() は、かっこの中のセレクターに一致する要素を持っている親を選ぶ擬似クラスです。かっこの中に :nth-child() を書くと、子の数で親を選べます。

書き方 一致する条件
.cards:has(> :nth-child(2)) 子が2個以上
.cards:has(> :last-child:nth-child(3)) 子がちょうど3個
.cards:has(> :nth-child(5)) 子が5個以上

2個以上の判定は、「2番目の子がいる」ことで決まります。ちょうど3個は、「最後の子が先頭から3番目でもある」ことで決まります。

この書き方で、項目が1個なら1列、2個なら2列、3個なら3列、4個なら2列を2段、5個以上なら3列にするカードの並びを作りました。

css
.cards {
  display: grid;
  gap: 8px;
  grid-template-columns: repeat(var(--cols, 1), 1fr);
}

/* 2個以上なら2列 */
.cards:has(> :nth-child(2)) {
  --cols: 2;
}

/* ちょうど3個なら3列 */
.cards:has(> :last-child:nth-child(3)) {
  --cols: 3;
}

/* 5個以上なら3列 */
.cards:has(> :nth-child(5)) {
  --cols: 3;
}

列の数はカスタムプロパティ --cols に入れ、条件ごとにその値だけを変えています。4個のときは「2個以上」の指定だけが当てはまるので、2列のまま2段になります。

項目が1個から5個までのリストを縦に並べた画面。1個は横幅いっぱいの1列、2個は2列、3個は3列、4個は2列で2段、5個は3列で2段目に2個
項目の数だけを変えた5つのリスト。列の数を決める CSS は上のコードと同じ

検証環境で項目の数を1個から7個まで変えて測ると、各項目の幅は次のようになりました。

項目の数 並び方 1つの項目の幅
1個 1列 600px
2個 2列 296px
3個 3列 194.7px
4個 2列×2段 296px
5〜7個 3列(7個は3段目に1個) 194.7px

項目を足したり消したりしても、その場で切り替わる

項目を JavaScript で1個ずつ足していくと、そのたびに列の数が変わりました。

js
const li = document.createElement('li');
li.textContent = '項目5';
document.querySelector('.cards').append(li);

空のリストから1個ずつ足すと、1列、2列、3列、2列×2段、3列の順に変わり、上の表と同じ並びになりました。5個から最後の1個を消すと、2列×2段に戻りました。項目の数を数えてクラスを付け替えるような JavaScript は、どこにも書いていません。

hidden の項目も数に入る

:nth-child() は、hidden 属性で隠した子も数えます。4個のうち1個に hidden 属性を付けて、見えている項目を3個にしたところ、上の CSS では3列にならず、2列で2段目に1個だけ残る並びになりました。

見えている項目だけを数えたいときは、:nth-child() の of を使う書き方で、数える対象を絞ります。:nth-child(4 of :not([hidden])) は、「hidden が付いていない子のうちの4番目」に一致します。ちょうど何個かを判定するときは、:last-child の代わりに、後ろから数える :nth-last-child() を使って :nth-last-child(1 of :not([hidden])) と書きます。こうすると、後ろから数えるときも hidden のない子だけが数に入ります。

css
.cards:has(> :nth-child(2 of :not([hidden]))) {
  --cols: 2;
}

.cards:has(> :nth-child(3 of :not([hidden])):nth-last-child(1 of :not([hidden]))) {
  --cols: 3;
}

.cards:has(> :nth-child(5 of :not([hidden]))) {
  --cols: 3;
}

この書き方にすると、4個のうち1個が hidden のリストは、隠した項目が末尾でも先頭でも3列になりました。hidden のない1〜5個のリストでは、最初の CSS と同じ並びのままでした。

かっこの中の > を省くと、項目の中の要素まで数える

:has(> :nth-child(2)) の > は、親の直接の子だけを調べるための記号です。これを省いて .cards:has(:nth-child(2)) と書くと、子の中にある要素まで探しに行きます。

項目が1個だけのリストで、その項目の中に小さなリスト(li が2個)を入れて試しました。> を省いた書き方では、項目の中の小さなリストの2個目の li が「2番目の子」に当たり、項目1個なのに2列になって、項目の幅が 296px に狭まりました。> を付けた書き方では、1列の 600px のままでした。項目の中にリストやボタンなどの要素が並ぶ場合は、> を必ず付けておきます。

子の側から数える書き方

:has() が使えないブラウザーにも合わせたいときは、子の側から数える方法があります。:first-child:nth-last-child(n+4) は、「先頭の子で、かつ後ろから数えて4番目以降」、つまり兄弟が4個以上あるときの先頭の子に一致します。そこから ~ で後ろの兄弟もまとめて選びます。

css
.cards {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.cards > li {
  flex: 1 1 0;
}

/* 4個以上なら、1つの項目を半分の幅にする */
.cards > li:first-child:nth-last-child(n+4),
.cards > li:first-child:nth-last-child(n+4) ~ li {
  flex: 0 0 calc(50% - 4px);
}

検証環境では、3個までは横1列に均等に並び(3個なら 194.7px ずつ)、4個と5個では 296px ずつの2列で折り返しました。

この書き方で変えられるのは、子のスタイルだけです。親の display や grid-template-columns は変えられないので、上の例でも親は flex のまま、子の幅で列のように見せています。:has() なら、親のほうの指定を直接切り替えられます。

sibling-count() で、数をそのまま計算に使う

新しいブラウザーだけでよければ、sibling-count() 関数も使えます。自分を含めた兄弟の要素の数を整数で返すので、calc() の中で割り算に使えます。

css
.cards {
  display: flex;
  gap: 8px;
}

.cards > li {
  width: calc((100% - (sibling-count() - 1) * 8px) / sibling-count());
}

項目の間の 8px を引いた残りを、項目の数で割っています。検証環境では、1個なら 600px、2個なら 296px、4個なら 144px、5個なら 113.6px と、1〜5個のどれでも横1列に均等に並びました。「何個以上なら何列」のように段階を分けるのではなく、数に応じて1つの項目の幅を細かく変えたいときに向いています。

sibling-count() も、hidden の項目を数に入れました。4個のうち1個を hidden にすると、見えている3個は 144px(4個のときの幅)のままでした。3個の幅と、間の 8px 2つを足しても 448px なので、600px の幅のうち 152px が余ります。sibling-count() には of のような絞り込みの書き方はありません。

項目が0個のときは :empty より :not(:has(> *))

項目がないときに「まだ項目がありません」と出したい場合は、子の要素がないことで判定します。

css
.cards:not(:has(> *))::before {
  content: "まだ項目がありません";
}

:empty でも空の要素を選べますが、空白や改行の文字も子として数えます。検証環境で、<ul></ul> と、中にコメントだけがある ul は :empty に一致しましたが、中に改行だけがある ul は一致しませんでした。HTML を整形して <ul> と </ul> を別の行に書くと、それだけで :empty が効かなくなります。:not(:has(> *)) は、3つとも一致しました。

ブラウザーの対応

互換性データでは、対応しているバージョンは次のとおりです。

機能 Chrome・Edge Firefox Safari(iOS を含む)
:has() 105 121 15.4
:nth-child() の of 111 113 9
:nth-last-child() Chrome 4・Edge 12 3.5 3.1(iOS は 2)
sibling-count() 138 154 26.2

sibling-count() は、:has() よりずっと新しいバージョンから対応しています。古いバージョンのブラウザーで見る人もいる場合は、:has() で段階ごとに列数を切り替える書き方を選びます。

​Posted by 86