ジャンルから探す

Web Design English

CSS has() セレクターで実現する新しいデザインパターン

:has()は、「中に○○がある要素」を選べる唯一のCSSセレクターです。2023年12月の Firefox 121 対応で全モダンブラウザが揃い、今は Baseline として安心して使えます。

正確に言うと「親セレクター」ではありません。直前の兄弟や、条件を満たす祖先も選べる関係疑似クラスです。この記事では、基本構文と誤解しやすい点、実際に効く7つのデザインパターン、そして:has()特有の落とし穴(詳細度、使えない場所)を整理します。

スポンサーリンク

:has() は何を選ぶのか

A:has(B)は、「Bにマッチする要素が見つかるようなA」を選びます。スタイルが当たるのはAであって、Bではありません。

.container:has(.child) {
  background-color: lightblue;
}

ここでよくある誤解が「子要素を持つ場合」という説明です。実際は子孫全体が対象で、何階層下にあってもマッチします。直下の子だけに限定したい場合は、>を明示します。

/* 何階層下でもマッチする */
.container:has(.child) { }

/* 直下の子だけ */
.container:has(> .child) { }

/* 直後の兄弟がある要素 */
h2:has(+ p) { }

/* 後続のどこかに兄弟がある要素 */
h2:has(~ .note) { }

この+~が使えることが重要です。CSSで「前の要素」を選ぶ手段は:has()しかありません

対応状況

ブラウザ バージョン リリース
Safari 15.4 2022年3月
Chrome / Edge 105 2022年9月
Firefox 121 2023年12月

パターン1:入力状態に応じて親の見た目を変える

最も使用頻度が高いのがこれです。チェックされたカードを強調する、といった実装がCSSだけで書けます。

<label class="card">
  <input type="checkbox" name="plan" value="a">
  <span>スタンダードプラン</span>
</label>
.card:has(input:checked) {
  border-color: #2a7;
  background: color-mix(in oklab, #2a7 8%, canvas);
}

/* 無効なカードは全体を薄く */
.card:has(input:disabled) {
  opacity: .5;
  cursor: not-allowed;
}

従来はJavaScriptでchangeを監視してクラスを付け外ししていた処理が、丸ごと不要になります。

スポンサーリンク

パターン2:エラーのある行だけを強調する

フォームの検証と組み合わせると効果的です。:invalidではなく:user-invalidを使うのがポイントで、こうするとページを開いた瞬間に全行が赤くなる、という事故を防げます。

/* ユーザーが操作したあとに不正な入力を含む行だけ赤くする */
.field:has(:user-invalid) {
  border-left: 3px solid #d33;
  background: #fff5f5;
}

.field:has(:user-invalid) .error-message {
  display: block;
}

/* 必須項目の行にマークを出す */
.field:has([required]) label::after {
  content: " *";
  color: #d33;
}

送信ボタンの制御にも使えます。

/* フォーム内に不正な値があればボタンを押せない見た目にする */
form:has(:invalid) button[type="submit"] {
  opacity: .5;
  pointer-events: none;
}

ここは:user-invalidではなく:invalidが適切です。未入力の必須項目がある間はボタンを無効にしたいためです。「入力欄の見た目は:user-invalid、送信可否の判定は:invalidと覚えておくと迷いません。

パターン3:直前の見出しの余白を詰める

:has()が無い時代、「見出しの直後に画像が来るときだけ余白を変える」といった調整はクラスを手で足すしかありませんでした。

/* 直後に図がある見出しは下余白を詰める */
h2:has(+ figure) {
  margin-bottom: .5rem;
}

/* 見出しが連続している場合(h2直後にh3)の詰め */
h2:has(+ h3) {
  margin-bottom: .25rem;
}

/* 画像しか入っていない段落は余白を消す */
p:has(> img:only-child) {
  margin: 0;
}

CMSやMarkdownから生成される本文のように、HTMLの構造をこちらで制御できない場面で特に威力を発揮します。

スポンサーリンク

パターン4:モーダル表示中に背景のスクロールを止める

<dialog>showModal()は背景の操作を無効化しますが、スクロールは止めません:has()を使うとJavaScript無しで対応できます。

body:has(dialog[open]) {
  overflow: hidden;
}

/* ポップオーバー表示中に背景を暗くする */
body:has([popover]:popover-open)::after {
  content: "";
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / .3);
}

この「状態を持つ要素を探して、ルート側のスタイルを変える」使い方は、:has()でしか書けません。ダイアログの実装はdialog要素の改善解説と使い方にまとめています。

パターン5:要素の数でレイアウトを変える(数量クエリ)

:has():nth-child()を組み合わせると、子要素の数によってレイアウトを切り替えられます。

/* 子が1つだけなら1カラム */
.grid:has(> :only-child) {
  grid-template-columns: 1fr;
}

/* 子が4つ以上あるとき(4番目が存在する)は3カラム */
.grid:has(> :nth-child(4)) {
  grid-template-columns: repeat(3, 1fr);
}

「アイテムが1つのときだけ中央に大きく置く」といった、これまでJavaScriptで件数を数えて分岐していた処理を置き換えられます。

パターン6:サイドバーの有無でレイアウトを切り替える

.layout {
  display: grid;
  grid-template-columns: 1fr;
}

.layout:has(> .sidebar) {
  grid-template-columns: 1fr 280px;
  gap: 32px;
}

@media (max-width: 768px) {
  .layout:has(> .sidebar) {
    grid-template-columns: 1fr;
  }
}

テンプレート側で.has-sidebarのようなクラスを付け分ける必要が無くなります。HTMLの構造がそのまま条件になるので、クラス名の付け忘れによる崩れが起きません。

パターン7::not() と組み合わせて「〜が無い場合」を選ぶ

:has()の否定は2通りの書き方があり、意味が違います。ここは間違えやすいので注意してください。

/* ① 画像を「持たない」カード */
.card:not(:has(img)) {
  padding: 24px;
}

/* ② 「画像以外の何か」を持つカード(画像も持っていてよい) */
.card:has(:not(img)) {
  /* ほぼ全てのカードにマッチしてしまう */
}

「〜が無いとき」を表したいなら、必ず①の:not(:has(...))の順です。②は意図しない広範囲にマッチします。

詳細度の計算に注意する

:has()自体は詳細度を持ちませんが、引数の中で最も詳細度の高いセレクターがそのまま加算されます:is():not()と同じ挙動です。

セレクター 詳細度
.card:has(img) 0,1,1(クラス1+要素1)
.card:has(.badge) 0,2,0(クラス2)
.card:has(#promo) 1,1,0(ID1+クラス1)

引数にIDを入れると詳細度が跳ね上がり、あとから上書きできなくなります。:has()の中にIDセレクターを書かないのが安全です。

:has() が使えない場所

仕様上、次の使い方はできません。エラーになるか、無視されます。

  • :has()の中に:has()を入れる(入れ子は不可)
  • 擬似要素に付ける::before:has(...) は不可
  • :has()の引数に擬似要素を書く:has(::before) は不可
  • 引数に:visitedを書く:プライバシー保護のため無視されます

また、<html><body>:has()を書くとページ全体の再計算対象になります。動作はしますが、頻繁に変化する条件(マウス移動に連動するなど)と組み合わせるのは避けた方が無難です。

パフォーマンスをどう考えるか

結論として、通常の使い方で問題になることはほぼありません。ブラウザ側でセレクターの評価は十分に最適化されています。

そのうえで、避けた方がよい書き方は次の2つです。

/* ❌ 全要素が評価対象になる */
*:has(div) { color: red; }

/* ❌ 条件が広すぎる */
div:has(span) { }

/* ✅ 起点を絞る */
.card:has(> .badge) { }

ポイントは「起点(:has()の左側)を具体的なクラスに絞る」ことと、「可能なら>+で探索範囲を限定する」ことです。この2つを守れば、計測して問題になるようなことはまず起きません。

非対応ブラウザへの配慮

2026年時点で対応していないのは、事実上サポート対象外の古いブラウザだけです。それでも装飾が壊れると困る場合は、@supports selector()で分岐します。

@supports selector(:has(a)) {
  .layout:has(> .sidebar) {
    grid-template-columns: 1fr 280px;
  }
}

@supports (...)ではなく@supports selector(...)である点に注意してください。セレクターの対応可否を調べる構文は別物です。

まとめ

  • A:has(B)はBを含むAを選ぶ。子だけに限定するなら:has(> B)と書く
  • +~を使えば「前の要素」を選べる。これはCSSで:has()だけができること
  • 入力状態(:checked / :user-invalid)と組み合わせるとJavaScriptが不要になる
  • 「〜が無い」は必ず:not(:has(...))の順で書く
  • 引数にIDを入れると詳細度が跳ね上がる。入れない
  • :has()の入れ子、擬似要素との併用、:visitedは不可
  • 機能検出は@supports selector(:has(a))

「CSSでは無理だからJavaScript」と判断していた実装の多くが、:has()で置き換えられます。フォームまわりの実装はHTML form要素の新機能と活用法、色の指定はCSS color()関数の使い方もあわせてどうぞ。