CSSとレイアウト

CSS :has():内側で何が起きているかを知る親要素

CSS :has():内側で何が起きているかを知る親要素

フォームのフィールドがあるとします。<div class="field">の中にラベルとinputが入っています。要件はごく単純です——inputが不正な値のとき、コンテナ全体に赤い枠と、ラベルの横に警告アイコンを表示したい。input自体だけでなく、です。.field input:invalidに手を伸ばし——壁にぶつかります。このセレクタは、赤い枠が欲しいならinputをスタイルすることになります。しかしあなたがスタイルしたいのは.field、つまりそのinputのであり、しかもその中で何が起きているかに基づいて決めたいのです。そして気づきます——CSSには何十というコンビネータ、疑似クラス、属性セレクタがあるにもかかわらず、25年の歴史の中でこれを実現する方法は一度も存在しなかったのだと。

これはあなたのCSSの知識の欠落ではありません。これはCSS1から2022年まで通用していた、セレクタのアーキテクチャの根本的な特徴です。

なぜコンビネータは常に一方向しか見ないのか

CSSのすべてのコンビネータ——スペース(子孫)、>(直接の子)、+(隣接する兄弟)、~(一般の兄弟)——は二つのセレクタの間の関係を表しますが、スタイルされる対象は常にコンビネータの右側にある要素です。.field input.fieldの中にあるinputをスタイルします。.field ~ .error.fieldの兄弟である.errorをスタイルします。方向は常に同じです。コンテキストから対象へ、その逆はありません。CSSエンジンは.fieldに出会っても、「中を覗き込んで」それに基づいて.field自身のスタイル判断を変える組み込みの仕組みを持っていません。

開発者たちは長年、二つの方法でこれを回避してきました。どちらも現実的なコストを伴います。一つ目はJavaScriptでinput/blurイベントを監視し、手動で親に.field--invalidクラスを付け加える方法。二つ目は、よりスマートですが壊れやすい、いわゆるcheckbox/radioハック——~を使って:checked状態に基づいて兄弟をスタイルするものですが、これは要素が実際にフラットなDOM構造の中で兄弟である場合にしか機能せず、実際のフォームが要求するような入れ子構造では機能しません。

jsx

// :has()以前の回避策 — 動くが、純粋にHTMLの状態の
// 視覚的な結果でしかないもののためにJSが必要になる
import { useState } from "react";
const FormField = ({ label, ...inputProps }) => {
const [isInvalid, setIsInvalid] = useState(false);
return (
<div className={`field ${isInvalid ? "field--invalid" : ""}`}>
<label>{label}</label>
<input
{...inputProps}
onBlur={(e) => setIsInvalid(!e.target.validity.valid)}
/>
</div>
);
};

:has()がこのコードを不要にするのは、それがより賢いトリックだからではありません——CSSの歴史上初めて、要素が自分自身の内側について問い合わせることを可能にする関係性を表す疑似クラスとして、問題を根本から解決するからです。

:has()が実際にどう動くのか:子孫ではなく要素自身に固定されるセレクタ

ここで理解しておくべき重要な発想の転換があります。.field:has(input:invalid)はinputをスタイルしません。スタイルするのは.field——疑似クラスが付いているまさにその要素——であり、ただしその内部のどこかに、カッコの中のセレクタにマッチする要素が存在することが条件です。あなたが「固定」する要素(アンカー要素)は常に同じままです。:has()はただ、それがマッチを得るかどうかを決めるだけです。

css

.field:has(input:invalid) {
border-color: #c62828;
}
.field:has(input:invalid) .field__icon {
visibility: visible;
}

デフォルトでは、:has()の内側にあるセレクタは任意の深さの任意の子孫を探します——通常のスペースと同じです。しかしカッコの中でコンビネータを直接使うことで、通常のCSSとまったく同じように関係を絞り込むこともできます。

css

/* 任意の子孫ではなく、直接の子だけ */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* .error-message が兄弟として「直後に」続く要素 */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

これによって:has()は単一の新しいトリックではなく、一般化された仕組みになります——以前はコンビネータで表現できたあらゆる関係を、「前方」だけでなく「内側」や「後方」に向けても表現できるようにするのです。

実践例:自分自身が不正であることを自ら知るフォームフィールド

これを完全な、アクセシブルなコンポーネントにまとめてみましょう。重要な細部があります。:invalidはそれ自体、required属性を持つ空のフィールドが読み込まれた直後から発火します——ユーザーがまだ何も入力していない、手つかずの空のフォームに赤い枠を表示してしまうことになります。これを解決するのが:not(:placeholder-shown)で、ユーザーが実際に何かをそのフィールドに残した場合にのみマッチします。

jsx

const FormField = ({ label, id, ...inputProps }) => (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} {...inputProps} />
<svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" />
</svg>
</div>
);
export default FormField;

css

.field {
position: relative;
border: 1px solid #ccc;
border-radius: 8px;
padding: 8px 12px;
transition: border-color 0.15s ease;
}
.field__icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
fill: #c62828;
visibility: hidden;
}
/* フィールドが「触れられて」いる(何か入力された)、かつ不正 — そのときだけ反応 */
.field:has(input:not(:placeholder-shown):invalid) {
border-color: #c62828;
background: #fff5f5;
}
.field:has(input:not(:placeholder-shown):invalid) .field__icon {
visibility: visible;
}
/* エラーと同じくらい、肯定的な確認も重要 */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

見た目に関与するJavaScriptは一行もありません。バリデーションの状態はすでにブラウザにネイティブに存在しています——:valid/:invalidはCSS3の時代からそこにあります——:has()はついに、その既存の状態をinputからそのコンテナへ、視覚的に必要とされていた場所へと運ぶことを可能にしただけなのです。

二つ目のパターン:自分が何を含んでいるかを知るコンポーネント

同じ仕組みが、まったく別の種類の問題も解決します——事前にそれを伝えるpropを用意することなく、特定のコンテンツの存在に依存するレイアウトです。

css

/* 画像を持つカードは2カラムのレイアウトに、画像のないカードはテキストの全幅表示に */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* セクションのタイトルは、そのセクションに実際にサブタイトルがある場合だけ下側の余白を得る */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* 要素が一つもないリストは、空のスペースではなく空状態を表示する */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "条件に一致する商品がありません。";
color: #666;
}

:has()がなければ、これらのケースはそれぞれ、親コンポーネントが手動で設定するprop(hasImageisEmpty)か、JSXの中での配列の長さのチェックと、それに基づいた条件付きの別クラスのレンダリングを必要としたでしょう。:has()によって、コンポーネントは外部から情報を伝えられるのではなく、自ら自分のコンテンツを認識して反応できるようになります——これはContainer Queriesの記事に登場した、自分のコンテナを自覚する商品カードとまったく同じ発想の方向性です。手動で受け渡されるstateを減らし、構造そのものから直接導かれるロジックを増やす、というものです。

落とし穴とグッドプラクティス

  • :has()の詳細度は、中にある最も詳細度の高いセレクタの詳細度であり、ゼロではない。 .field:has(input:invalid)はクラス二つと疑似クラス一つを合わせた詳細度を持ちます——:has()が詳細度に「カウントされない」などとあてにしないでください。実務上、後から書かれたよりシンプルなルールを、頻繁に打ち負かしてしまいます。
  • コンビネータなしで深く入れ子になった広範な:has()はコストがかかりうる。 .app:has(.some-deeply-nested-element)は理論上、.appの内側でDOMに変化があるたびに、その部分木全体をエンジンに検討させることになります。最近のエンジン(Chromium、WebKit)はいわゆるinvalidation setによってこれを最適化しています——盲目的にすべてを再計算するわけではありません——が、その効果は具体的なセレクタとツリーの規模次第です。可能な限り、デフォルトの深い探索ではなく、コンビネータ(直接の子を表す>など)で関係を絞り込みましょう。
  • :has():focus-withinの代わりにはならない。 「子孫がフォーカスされたときに親が反応する」だけが必要なら、:focus-withinは昔からあり、計算コストも安く、読みやすさも上です——:has(:focus)はほぼ同じ効果を与えますが、専用のツールがすでに存在する場面でわざわざより汎用的な道具に手を伸ばす理由はありません。
  • ブラウザのサポートはもはや問題ではなくなった。 :has()はこのパズルの最後の大きなピースとして登場しました——SafariはVersion 15.4(2022年3月)から、ChromeはVersion 105(2022年8月)から、Firefoxは最後に、Version 121(2023年12月)で加わりました。それ以来、新しいプロジェクトであれば@supportsなしで安全に使えます。

まとめ

25年間、CSSは一方向にしか向かない関係性しか表現できませんでした——コンテキストから対象へ、親から子へ、先行するものから後続するものへ。:has()は同じ発想の別のバリエーションではなく、要素が自分自身の内側について問い合わせ、そこで見つけたもの——フィールドのバリデーションエラー、カード内の画像の有無、リスト上の要素の欠如——に反応できる初めての疑似クラスです。これによって、これまでJavaScriptや、上から手動で渡されるpropに頼らざるを得なかった一連の判断が、本来それが属すべき場所——HTMLの構造と、その構造を記述するCSSのルール——へと戻ってくるのです。