テクニック

CSS :has() セレクタの使い方|親を選べると何が変わるか

この記事の対象: CSSを日常的に書いているWeb制作者・フロントエンドエンジニア
読了時間: 約12分

これまでCSSは「親から子」の一方通行でした。子の状態を見て親の見た目を変えたいとき、クラスを付け外しするJavaScriptを書くしかなかった。:has() はその制約を外すセレクタです。画像のないカードだけ組みを変える、エラーを抱えた行だけ色を変える、モーダルが開いている間だけ body のスクロールを止める——これまでJSが担っていた「状態をクラスに翻訳する」仕事の相当部分が、CSSだけで片付きます。ただし万能ではなく、書き方によっては描画コストを払うことになります。使ってよい場面と避ける場面を、動くコードで切り分けます。


CSS :has() セレクタとは何か|親を条件で選ぶ

CSS :has() セレクタとは何か|親を条件で選ぶ

基本の形は「自分が◯◯を含んでいたら」

:has() は、引数に書いたセレクタに一致する要素を内側に持つ要素そのものを選びます。

/* img を直接の子に持つ .card だけが対象 */
.card:has(> img) {
  display: grid;
  grid-template-columns: 160px 1fr;
  gap: 20px;
}

読み下すと「.card のうち、直下に img があるもの」。選ばれるのは img ではなく .card 側です。ここがこれまでのセレクタと決定的に違う点で、だから「親セレクタ」と呼ばれます。

引数にはセレクタリストが書けるので、複数条件をまとめられます。

/* 直下に img か video か picture のどれかを持つ */
.card:has(> img, > video, > picture) {
  padding-block-start: 0;
}

直下に限るか、子孫まで見るか

結合子を書くかどうかで、探す範囲が変わります。これを曖昧にしたまま書くと、意図しない要素まで巻き込みます。

書き方 意味
.card:has(img) .card の子孫のどこかに img がある
.card:has(> img) .card の直下に img がある
.card:has(+ .note) .card の直後の兄弟が .note
.card:has(~ .error) .card の後ろの兄弟のどこかに .error がある

実務では >(直下)を付けるほうが安全です。入れ子の深いコンポーネントで、内側の別パーツが持つ img を拾ってしまう事故を防げます。

否定と組み合わせると「無い側」を選べる

:has() が本当に効くのは、むしろ「持っていない要素」を選べるときです。

/* 画像を持たないカードは1カラムに落とす */
.card:not(:has(> img)) {
  display: block;
  padding: 24px;
}

/* 画像を持たないカードの見出しだけ大きくする */
.card:not(:has(> img)) .card__title {
  font-size: 1.25rem;
}

従来なら「画像なしのとき .card--no-image を付ける」というルールをテンプレート側に書いていました。Liquid でも PHP でも React でも、出力する側に条件分岐が増える。:has() はその分岐をCSSに引き取ります。マークアップは一種類のまま、CSSだけが場合分けする——これが一番大きな変化です。

クラス付与と :has() で分岐の置き場所が変わる

:has() で分岐

  • マークアップは1種類
  • 条件はCSSに集約
  • テンプレート側は無改修

クラス付与で分岐

  • テンプレートに if が増える
  • CMS・JS・CSSの3か所に知識が散る
  • クラス名の揺れが起きる

分岐の総量が減るわけではなく、置き場所が移るだけです。ただしCSSに寄せると、見た目に関する知識が1ファイルにまとまるので、後から触る人が探す場所が減ります。


実務で効く :has() の使い方|そのまま動くコード4本

実務で効く :has() の使い方|そのまま動くコード4本

1. 画像の有無でカードの組みを変える

ギャラリーやお知らせ一覧で、サムネイルがある記事とない記事が混ざるケース。これが :has() の入門として一番わかりやすい例です。

.news-list {
  display: grid;
  gap: 16px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.news-card {
  display: block;
  padding: 20px;
  border: 1px solid #d6d2cb;
  border-radius: 12px;
  background: #ffffff;
  color: #1f1c18;
  text-decoration: none;
}

/* 画像があるときだけ2カラムに切り替える */
.news-card:has(> .news-card__thumb) {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 16px;
  align-items: start;
}

.news-card__thumb {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
}

/* 画像がないときは本文を広く使い、抜粋を2行見せる */
.news-card:not(:has(> .news-card__thumb)) .news-card__excerpt {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

@media (width < 600px) {
  .news-card:has(> .news-card__thumb) {
    grid-template-columns: 88px 1fr;
    gap: 12px;
  }
}

HTMLはどちらも同じ構造で、img があるかないかだけが違います。

<ul class="news-list">
  <li>
    <a class="news-card" href="/news/1/">
      <img class="news-card__thumb" src="/img/news1.webp" alt="" width="480" height="360">
      <div>
        <p class="news-card__date">2026.10.20</p>
        <h3 class="news-card__title">秋の展示会に出展します</h3>
        <p class="news-card__excerpt">来月の展示会に出展します。会場では新作のサンプルをご覧いただけます。</p>
      </div>
    </a>
  </li>
  <li>
    <a class="news-card" href="/news/2/">
      <div>
        <p class="news-card__date">2026.10.14</p>
        <h3 class="news-card__title">年末年始の営業について</h3>
        <p class="news-card__excerpt">年末年始の休業期間をお知らせします。お問い合わせへの返信は営業開始日以降となります。</p>
      </div>
    </a>
  </li>
</ul>

CMSの出力テンプレートに {% if image %} を増やさずに済みます。WordPress で一覧を組むときの考え方は、投稿タイプ側の設計と合わせて読むと見通しがよくなります。

2. 入力エラーのある行だけ目立たせる

フォームのバリデーション表示は、これまで「エラー時に親へ .is-error を付ける」JSを書いていた領域です。:has() と :user-invalid を組み合わせると、クラス付与なしで書けます。

.field {
  display: grid;
  gap: 6px;
  padding: 12px;
  border-radius: 8px;
  border: 1px solid transparent;
}

.field > label {
  font-weight: 700;
  color: #332f2a;
}

.field > input,
.field > textarea {
  padding: 10px 12px;
  border: 1px solid #9a948c;
  border-radius: 6px;
  font-size: 1rem;
  background: #ffffff;
  color: #1f1c18;
}

/* 一度触って離れた後に不正な値なら、行ごと赤系に寄せる */
.field:has(:user-invalid) {
  background: #fdf1f0;
  border-color: #b3261e;
}

.field:has(:user-invalid) > label {
  color: #8c1d18;
}

.field:has(:user-invalid) > input,
.field:has(:user-invalid) > textarea {
  border-color: #b3261e;
}

/* エラーメッセージは通常は隠し、不正なときだけ出す */
.field__error {
  display: none;
  font-size: 0.875rem;
  color: #8c1d18;
}

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

/* 必須項目のラベルに印を付ける */
.field:has([required]) > label::after {
  content: "必須";
  margin-inline-start: 8px;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 0.75rem;
  background: #332f2a;
  color: #ffffff;
}
<div class="field">
  <label for="email">メールアドレス</label>
  <input id="email" name="email" type="email" required
         aria-describedby="email-error">
  <p class="field__error" id="email-error">メールアドレスの形式で入力してください。</p>
</div>

ここで必ず :user-invalid を使ってください。:invalid は入力前の空欄にも当たるため、ページを開いた瞬間に未入力の必須項目がすべて赤くなります。「まだ何もしていないのに怒られた」という体験になり、特に項目数の多い問い合わせフォームで離脱の原因になります。:user-invalid は一度フォーカスして離れた後、または送信を試みた後にだけ当たる疑似クラスです。

もうひとつ、色だけでエラーを伝えないこと。上のコードが .field__error のテキストを同時に出しているのはそのためです。色覚特性のある利用者には赤の背景が伝わりません。aria-describedby でエラー文を入力欄に紐付けておけば、スクリーンリーダーがフォーカス時に読み上げます。:has() は見た目を切り替えるだけで、支援技術に状態を伝えるのは aria-* 属性の仕事です。ここは分担が変わりません。

赤背景 #fdf1f0 に文字色 #8c1d18 の組み合わせは、通常サイズの文字で必要とされるコントラスト比 4.5:1 を満たします。自分の配色に差し替えるときは、必ず比率を測り直してください。薄い赤に中間調の赤文字という組み合わせは、見た目の印象よりコントラストが取れていないことが多い配色です。

3. モーダルが開いている間、背面のスクロールを止める

これも長年JSの仕事でした。document.body.style.overflow = 'hidden' を書き、閉じるときに戻す。戻し忘れてスクロールが死ぬバグも定番です。

/* dialog 要素を使う場合 */
body:has(dialog[open]) {
  overflow: hidden;
}

/* 自前のモーダルで、開閉を属性で持っている場合 */
body:has(.modal[aria-hidden="false"]) {
  overflow: hidden;
}

.modal-backdrop {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 24px;
  background: rgb(31 28 24 / 0.6);
}

.modal {
  width: min(560px, 100%);
  max-height: 80dvh;
  overflow-y: auto;
  padding: 28px;
  border-radius: 14px;
  background: #ffffff;
  color: #1f1c18;
}

同じ考え方で、ハンバーガーメニューの開閉にも使えます。チェックボックスを状態の受け皿にすれば、JSゼロで書けます。

.nav-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.nav-panel {
  position: fixed;
  inset-block: 0;
  inset-inline-end: 0;
  width: min(320px, 86vw);
  padding: 72px 24px 24px;
  background: #ffffff;
  translate: 100% 0;
  transition: translate 0.24s ease;
}

body:has(.nav-toggle:checked) .nav-panel {
  translate: 0 0;
}

body:has(.nav-toggle:checked) {
  overflow: hidden;
}

/* アニメーションを減らす設定なら、移動させずに切り替える */
@media (prefers-reduced-motion: reduce) {
  .nav-panel {
    transition: none;
  }
}
<input class="nav-toggle" type="checkbox" id="nav-toggle">
<label class="nav-button" for="nav-toggle">メニュー</label>
<nav class="nav-panel" aria-label="メインメニュー">
  <ul>
    <li><a href="/about/">わたしたちについて</a></li>
    <li><a href="/works/">制作実績</a></li>
    <li><a href="/contact/">お問い合わせ</a></li>
  </ul>
</nav>

ただしこのチェックボックス方式には、アクセシビリティ上の明確な弱点があります。チェックボックスは意味としてはオン・オフの入力欄であって、開閉ボタンではありません。スクリーンリーダーは「チェックボックス、未選択」と読み上げます。閉じている間もパネル内のリンクはフォーカス順に残るので、Tabキーで見えない場所へ移動してしまいます。

商用の案件、特に公共性のあるサイトや採用サイトでは、<button aria-expanded> を使い、閉じているときは inert 属性か display: none でフォーカスを外す実装にしてください。JSは数行で済みます。

<button class="nav-button" type="button"
        aria-expanded="false" aria-controls="nav-panel">メニュー</button>
<nav class="nav-panel" id="nav-panel" aria-label="メインメニュー" inert>
  <!-- 中身 -->
</nav>
const button = document.querySelector('.nav-button');
const panel = document.querySelector('#nav-panel');

button.addEventListener('click', () => {
  const open = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!open));
  panel.toggleAttribute('inert', open);
});
/* 状態は aria-expanded が持ち、見た目の切り替えだけ :has() が担当する */
body:has(.nav-button[aria-expanded="true"]) {
  overflow: hidden;
}

body:has(.nav-button[aria-expanded="true"]) .nav-panel {
  translate: 0 0;
}

:has() は状態を読む側に回し、状態を持つのは aria-expanded にする。この分担にすると、支援技術にも正しく伝わり、CSSからも参照できます。採用サイトのような、幅広い環境から見られるサイトでは特にこの形を守ってください。

4. 直前・直後の要素との関係で余白を決める

見出しの直後に図が来るときだけ余白を詰める、といった組版の調整。これまでは + 結合子で「後ろの要素」しか触れませんでした。:has(+ ...) なら「前の要素」を選べます。

.prose h2 {
  margin-block: 2.5em 0.8em;
}

/* 見出しの直後が図版なら、見出しの下の余白を詰める */
.prose h2:has(+ figure) {
  margin-block-end: 0.4em;
}

/* 図版の直後が見出しなら、図版の下の余白を広げる */
.prose figure:has(+ h2) {
  margin-block-end: 3em;
}

/* 段落が1つしかないセクションは、上下の詰まりを均す */
.section:has(> p:only-child) {
  padding-block: 32px;
}

日本語の組版では、要素どうしの関係で余白を決める場面が多く、これまでテンプレートを分けるか、:first-child の連鎖でごまかしていた部分が素直に書けます。文字サイズの流体調整と合わせると、1つのCSSでPCからスマホまで通せます。


JavaScript はどこまで置き換わるか|境界を決める

JavaScript はどこまで置き換わるか|境界を決める

置き換わるもの、置き換わらないもの

:has() が肩代わりできるのは「DOMの状態を見て、クラスを付け外しするだけ」のコードです。ロジックのないJSほど、きれいに消えます。

JSでやっていたこと :has() で置き換えられるか
子要素の有無で親にクラスを付ける ◎ 完全に置き換わる
フォームの入力状態で親の色を変える ◎ :user-invalid などと併用
モーダル開閉中に body を固定 ◎ 状態が属性に出ていれば可
選択中のタブに応じてパネルを出す ○ ラジオボタン+:has() で可能
スクロール量で見出しを固定 × :has() の守備範囲外
入力値の内容を検証して文言を変える × 値の中身は見られない
非同期で取得した結果を反映 × DOMを書き換える側が必要

境界はシンプルで、:has() が見られるのはDOMの構造と属性と疑似クラスだけです。値の中身、スクロール位置、時間、通信結果は見られません。そこを扱うJSは残ります。

残したJSは状態の所有者にする

前のセクションのナビの例がそのまま答えです。JSは aria-expanded を書き換えるだけにして、見た目の切り替えは全部CSSに置く。こうすると、デザインを直すときにJSを読まなくて済みます。

状態の持ち方と見た目の切り替えを分ける
操作クリック・入力・フォーカス移動が起きる
JSaria-expanded など意味のある属性を書き換える
:has()その属性を読んで親側の見た目を切り替える

JSが触るのは属性1つ、CSSはその属性を条件に書く。この分け方にしておくと、支援技術への伝達とデザインの更新が同じ場所で食い違いません。

置き換えすぎない判断

チェックボックスとラジオボタンを「状態の入れ物」として使う書き方は、タブUIやアコーディオンでも成立します。成立はしますが、意味としては入力欄のままです。

  • 社内ツール・プロトタイプ・短命なキャンペーンページ:JSなしで速く組めるので使ってよい
  • コーポレートサイト・採用サイト・医療や金融など説明責任が重い案件:button + aria-expanded + inert で組む。CSSのみの実装にしない
  • ECサイトの購入導線:キーボードだけで最後まで操作できるかを実機で確認してから採用を決める

「CSSだけで書けた」は目的ではありません。操作できる人の幅が狭まるなら、JSを数行書くほうが正解です。


:has() の表示速度への影響|どこで重くなるか

:has() の表示速度への影響|どこで重くなるか

重くなる理由

通常のセレクタは、要素を見つけた時点でマッチ判定が終わります。:has() は違います。「この要素は条件を満たす子孫を持つか」を調べるため、ブラウザは内側を探しに行く必要があります。しかも子孫側が変化したとき、親のマッチ状態が変わるかもしれないので、再計算の範囲が広がります。

実装系は最適化を重ねていて、素朴に書く分には体感できる差は出にくくなっています。問題になるのは書き方が雑なときです。

避けたい書き方

/* 悪い例:全要素を起点に、全子孫を探す */
*:has(.active) { outline: 2px solid red; }

/* 悪い例:起点が広すぎる */
div:has(img) { padding: 0; }

/* 悪い例:深い子孫を無指定で探す */
.page:has(.very .deep .nested .thing) { background: #eee; }

推奨する書き方

/* 起点をクラスで絞る */
.card:has(> img) { /* ... */ }

/* 直下に限定する */
.field:has(> input:user-invalid) { /* ... */ }

/* body 起点は、条件を具体的な属性に絞る */
body:has(.nav-button[aria-expanded="true"]) { /* ... */ }

指針は3つです。

  1. 起点を具体的なクラスにする。* と裸の要素セレクタを起点にしない
  2. 可能な限り > を付けて直下に限る。探索の深さがそこで止まる
  3. 頻繁に変化する要素を条件に入れるときほど、起点を絞る。入力のたびに再計算が走る :user-invalid のような条件は、.field のような小さい範囲を起点にする

body 起点は例外的に許容する

body:has(...) は起点が最上位なので一見重そうですが、実際にはページ全体で1要素しか起点がないため、起点を広げたことによる増殖が起きません。条件側を属性セレクタで具体的に書けば、実務上の問題はまず出ません。モーダルやナビの背面固定で body:has() を使うのは妥当な選択です。

測り方

推測で避けるより、測って判断してください。

  • Chrome DevTools の Performance パネルで記録し、Recalculate Style の時間を見る
  • :has() を含むルールをコメントアウトした版と比較する
  • 一覧ページなど、同じ構造が数百件並ぶページで測る。10件のページで差は出ません

問題が出やすいのはリストが長いページです。商品一覧やギャラリーのように、同じカードが何百個も並ぶ画面で .card:has(...) を複数書くときだけ、一度測っておけば十分です。

画像とフィルターの影響は別の話

:has() でレイアウトを切り替えるとき、本当に重いのはたいてい :has() ではなく中身です。

  • カード内の画像に width / height か aspect-ratio が無いと、画像の読み込み完了時にレイアウトがずれる(CLSの悪化)
  • backdrop-filter や大きな blur() を :has() の条件で出し入れすると、切り替えのたびに合成処理が走る
  • object-fit: cover で大きな画像を小さな枠に押し込むと、縮小の負荷は画像の実寸に比例する

:has() でレイアウトが変わる要素には、画像の寸法指定を必ず書いてください。先ほどのコードで aspect-ratio: 4 / 3 と width / height を両方入れているのはそのためです。質感を足す装飾系の表現を組み合わせる場合も、重いフィルターの出し入れは控えめにするのが安全です。


ブラウザ対応と安全な導入|落ちたときどう見えるか

ブラウザ対応と安全な導入|落ちたときどう見えるか

対応状況の確認方法

:has() は主要なモダンブラウザに実装済みですが、具体的なバージョンをここに書いても数か月で古くなります。確認はこの3つで行ってください。

方法 使いどころ
caniuse.com で :has を検索 バージョンとシェアを一度に見る
MDN の :has() ページのブラウザ互換性表 細かい挙動差を確認する
Google Analytics のブラウザ別レポート その案件の実際の訪問者が何を使っているか

3つ目が一番大事です。一般的なシェアではなく、その案件の訪問者のシェアで判断してください。社内システムで古い環境が固定されている、高齢層が多く更新していない端末が混じる、といったケースは実在します。

機能クエリで確実に分ける

@supports で対応状況を直接問い合わせられます。

/* まず、非対応環境でも成立する基本の見た目を書く */
.card {
  display: block;
  padding: 20px;
  border: 1px solid #d6d2cb;
  border-radius: 12px;
}

.card__thumb {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  margin-block-end: 12px;
}

/* 対応環境でだけ、画像ありカードを2カラムに引き上げる */
@supports selector(:has(img)) {
  .card:has(> .card__thumb) {
    display: grid;
    grid-template-columns: 140px 1fr;
    gap: 16px;
    align-items: start;
  }

  .card:has(> .card__thumb) .card__thumb {
    margin-block-end: 0;
  }
}

@supports selector(...) はセレクタの対応可否を問い合わせる書き方です。@supports (display: grid) のようなプロパティの問い合わせとは構文が違うので注意してください。

落ちたときにどう見えるかを先に決める

:has() が効かない環境で、レイアウトが崩れるのか、装飾が1つ減るだけなのか。これを設計段階で決めます。

非対応環境での落ち方で採用可否を決める

採用してよい

  • 落ちても1カラムで読める
  • 余白の最適化が効かないだけ
  • 色の強調が付かないだけ

別の手段を使う

  • 落ちるとメニューが開かない
  • 落ちるとエラーが見えない
  • 落ちると購入ボタンが隠れる

左側は「無くても困らない上乗せ」、右側は「無いと機能しない」。右側に当たる場面では :has() を唯一の手段にせず、JSでクラスを付ける実装を併用してください。

セレクタ全体が無効になる落とし穴

:has() の引数に書いたセレクタをブラウザが理解できない場合、:has() を含むルール全体が無効になります。これは :is() や :where() の寛容さとは挙動が違う点です。引数の中で新しい疑似クラスを使うときは、その疑似クラスの対応状況も合わせて確認してください。

/* 引数側の :user-invalid も対応が必要 */
.field:has(:user-invalid) { /* ... */ }

アニメーションを減らす設定への配慮

:has() で状態が切り替わるとき、トランジションを付けたくなります。前庭障害のある利用者にとって、大きな動きは体調不良の原因になります。prefers-reduced-motion の分岐を必ず入れてください。

.panel {
  transition: translate 0.24s ease, opacity 0.24s ease;
}

@media (prefers-reduced-motion: reduce) {
  .panel {
    transition-duration: 0.01ms;
  }
}

transition: none ではなく極端に短い時間にしておくと、transitionend を待つJSがある場合でも止まりません。動き自体は消えます。


案件の種類別|使うかどうかの判断

案件の種類別|使うかどうかの判断

ここは言い切ります。

案件の種類 判断 理由
コーポレートサイト 使う(レイアウト調整まで) 画像有無の出し分け・余白の最適化は、落ちても読める
採用サイト 使う。ただし開閉UIはJS併用 閲覧環境の幅が広い。メニューが開かない事故は致命的
ECサイト 使う。購入導線は慎重に カード一覧には効く。カート・決済の表示制御には単独で使わない
医療・金融・公共系 装飾の範囲のみ 対応環境の制約が厳しい案件がある。機能に関わる部分は従来手法
キャンペーンLP 積極的に使う 寿命が短く、ターゲット環境も絞られる。実装速度が効く
社内ツール・管理画面 積極的に使う 動作環境を指定できる。フォームのエラー表示で効果が大きい
既存サイトの部分改修 影響範囲を測ってから 既存CSSとの詳細度の衝突が起きやすい

最後の行は見落とされがちです。:has() は詳細度を引数の中で最も高いセレクタから計算します。.card:has(#main) は ID 相当の重さになり、既存の上書きルールを無言で貫通します。既存サイトに後から足すときは、詳細度を上げすぎない引数を選んでください。詳細度を上げたくない場合は :where() を挟みます。

/* 引数の詳細度を 0 に抑える */
.card:has(:where(#main)) { /* ... */ }

よくある質問

よくある質問

:has() を使えば JavaScript は要らなくなりますか

いいえ。:has() が参照できるのはDOMの構造・属性・疑似クラスだけです。入力値の中身、スクロール位置、通信結果、時間経過は扱えません。置き換わるのは「子の状態を見て親にクラスを付けるだけ」のコードで、ロジックを持つJSは残ります。残したJSは aria-expanded のような意味のある属性を書き換える役に徹させ、見た目の切り替えをCSSに寄せると保守しやすくなります。

:has() は本当にページを重くしますか

書き方次第です。*:has(...) や裸の div:has(...) のように起点が広いルールは、探索の対象が増えて再計算が重くなります。起点を具体的なクラスに絞り、:has(> img) のように直下へ限定すれば、通常のページで体感できる差はまず出ません。心配なら Chrome DevTools の Performance パネルで Recalculate Style の時間を、同じカードが数百件並ぶページで測ってください。

フォームのエラー表示には :invalid と :user-invalid のどちらを使うべきですか

:user-invalid を使ってください。:invalid は入力前の空欄にも当たるため、ページを開いた瞬間に未入力の必須項目がすべてエラー表示になります。:user-invalid は一度フォーカスして離れた後、または送信を試みた後にだけ当たります。あわせて、色だけでエラーを伝えず、テキストのメッセージを aria-describedby で入力欄に紐付けてください。

非対応ブラウザではどう見えますか

:has() を含むルールがまるごと無視されます。つまり :has() を書く前の状態がそのまま表示されます。だから「落ちたときの見た目」を先に書き、@supports selector(:has(img)) の中で上乗せする順番にしてください。メニューが開かない、エラーが見えない、といった機能が死ぬ使い方だけは避けます。

:has() の詳細度はどう計算されますか

引数の中で最も詳細度の高いセレクタが、そのまま :has() の詳細度になります。.card:has(#main) はID相当の重さを持ち、既存の上書きルールを貫通します。既存サイトへ後から足すときは、引数に :where() を挟んで詳細度を0に抑えると、予期しない上書きを防げます。


まとめ

:has() は「親を選べる」という一点で、CSSの書き方の前提を変えました。実務での要点を整理します。

  • 分岐の置き場所がテンプレートからCSSへ移る。マークアップを1種類に保ったまま、画像の有無や入力状態で見た目を変えられる
  • 起点を絞り、直下に限る。*:has() と裸の要素セレクタを起点にしない。body:has() は起点が1つなので例外的に使ってよい
  • 状態はJSが属性で持ち、見た目はCSSが切り替える。この分担にすると支援技術にも正しく伝わる
  • エラー表示は :user-invalid と、色以外の手がかりを必ずセットで。:invalid 単独は初見で全部赤くなる
  • 落ちたときの見た目を先に書く。機能が死ぬ場所には単独で使わない

使いどころは「無くても困らないが、あると読みやすくなる」上乗せの層です。そこに限れば、導入のリスクはほとんどありません。逆に、メニューの開閉や購入ボタンの表示制御のような機能の根幹に置くと、非対応環境で画面が使えなくなります。この線引きさえ守れば、今日から案件に入れて問題ない道具です。