1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

:has()で不要になったJavaScript 10選

1
Posted at

:has() が全モダンブラウザで使えるようになって(Baseline widely available / 2023年12月〜)、そろそろ3年が経ちました。

それなのに、現場のコードにはまだこういうJavaScriptが残っています。

// 入力エラーになったら親要素にクラスを付ける
input.addEventListener('blur', () => {
  input.closest('.field').classList.toggle('is-error', !input.checkValidity());
});

これ、CSS 1行で済みます。

この記事では「これまでJSでクラスを付け外ししていたが、:has() で不要になったもの」を10個、Before / After のコード付きで並べます。最後に、:has() の落とし穴と、逆にJSを消すべきではないケースもまとめます。

この記事で扱う :has() の3つの力

実例に入る前に、:has() が何をできるのかを3行で。

できること
親を選べる(いわゆる親セレクタ) .card:has(img) = 画像を含むカード
後ろを先読みできる h2:has(+ p) = 直後に p がある h2
論理演算ができる :has(a):has(b) = AND、:has(a, b) = OR、:not(:has(a)) = NOT

CSSがこれまで「自分より前・自分より上」しか見られなかったのが、:has()下にも後ろにも目が届くようになった、というのが本質です。


フォーム編

1. エラー時にフィールド全体のスタイルを変える

入力欄だけでなく、ラベルや枠、ヘルプテキストまで含めて赤くしたい——という一番よくある要件です。

Before(JS)

const input = document.querySelector('#email');
input.addEventListener('blur', () => {
  input.closest('.field').classList.toggle('is-error', !input.checkValidity());
});
input.addEventListener('input', () => {
  input.closest('.field').classList.remove('is-error');
});

After(CSS)

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

ポイントは :invalid ではなく :user-invalid を使うこと。:invalid は必須項目が空のままページを開いた瞬間からマッチしてしまうので、まだ何も入力していないユーザーにいきなり赤い画面を見せることになります。

:user-invalid は「ユーザーがその欄を操作したあと」にだけマッチします。上のJSが blur を待っていたのと同じ挙動を、ブラウザが標準で持っているわけです。こちらも現在は widely available です。

<div class="field">
  <label for="email">メールアドレス</label>
  <input id="email" type="email" required>
  <p class="error-message">正しい形式で入力してください</p>
</div>

2. 必須項目のマークをラベルに自動で付ける

Before(JS)

document.querySelectorAll('input[required]').forEach((input) => {
  const label = document.querySelector(`label[for="${input.id}"]`);
  label?.insertAdjacentHTML('beforeend', '<span class="required">*</span>');
});

After(CSS)

.field:has(input:required) > label::after {
  content: "*";
  color: crimson;
  margin-inline-start: 0.25em;
}

required 属性を足したら見た目も自動で追従するので、「属性は付けたのにマークを付け忘れた」という事故が構造的に起きなくなります。

注意content で生成したテキストは、多くのスクリーンリーダーが読み上げます。「必須」であることは required 属性がすでに支援技術に伝えているので、この * は装飾の二重掲載になります。気になる場合は視覚的な記号は最小限にし、「必須」という語自体はラベルのテキストとして書くほうが安全です。

3. フローティングラベル

入力があるときだけラベルを上に飛ばす、あのUIです。

Before(JS)

input.addEventListener('input', () => {
  input.closest('.field').classList.toggle('has-value', input.value !== '');
});

After(CSS)

.field:has(input:not(:placeholder-shown)) > label,
.field:has(input:focus) > label {
  translate: 0 -1.4em;
  scale: 0.8;
}

:placeholder-shown は「プレースホルダーが表示されている=未入力」にマッチするので、その否定が「入力済み」になります。HTML側では placeholder=" "(半角スペース1個)を入れておくのが定石です。

<div class="field">
  <input id="name" type="text" placeholder=" ">
  <label for="name">お名前</label>
</div>

:has() がなかった時代は、ラベルを入力欄の後ろに置いて input:not(:placeholder-shown) + label という兄弟セレクタで書く必要があり、DOM順が不自然になっていました。:has() なら親から見られるので、素直な順序で書けます。

4. 未入力があるフォームで送信ボタンを「未完了」に見せる

Before(JS)

form.addEventListener('input', () => {
  submitBtn.disabled = !form.checkValidity();
});

After(CSS)

form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  cursor: not-allowed;
}

ただし、これは見た目だけです。ここは :has() の限界として正直に書いておきます。

CSSは disabled 属性を付けられないので、ボタンは押せますし、支援技術にも「無効」とは伝わりません。とはいえ実はこれでいいケースが多いです。送信ボタンを disabled にしてしまうと、

  • フォーカスを受け取れないので、「なぜ押せないのか」に気付けない
  • 何が足りないのかを伝える手段がない

という問題があり、アクセシビリティの観点では「押せるが、押したらエラー箇所にフォーカスを移す」ほうが推奨されます。CSSは薄いヒントを出すだけに留め、判定と通知はJSとHTMLに任せる——この役割分担が健全です。


レイアウト編

5. 画像の有無でカードのレイアウトを切り替える

CMSやAPIから来るデータでは「画像があるものと無いもの」が混ざります。

Before(JS)

document.querySelectorAll('.card').forEach((card) => {
  card.classList.toggle('card--no-image', !card.querySelector('img'));
});

After(CSS)

.card:has(> img) {
  grid-template-columns: 120px 1fr;
}
.card:not(:has(> img)) {
  grid-template-columns: 1fr;
  padding-block-start: 1.5rem;
}

サーバー側でクラスを出し分ける必要も、テンプレートに条件分岐を足す必要もありません。HTMLの中身そのものが条件になるのが :has() の気持ちよさです。

6. 見出しと本文のマージンを「後ろを見て」調整する

CSSが長年できなかった「先読み」の代表例です。

Before(JS)

document.querySelectorAll('h2').forEach((h2) => {
  if (h2.nextElementSibling?.tagName === 'P') h2.classList.add('tight');
});

After(CSS)

/* 直後が p のときだけ、見出しの下マージンを詰める */
h2:has(+ p) {
  margin-block-end: 0.5em;
}

/* 直後が別の見出しなら、逆に空ける */
h2:has(+ h3) {
  margin-block-end: 1.5em;
}

+~:has() の中に書くと、「自分の後ろに何があるか」で自分自身のスタイルを決められます。.prose のような記事本文スタイルを書くときに効きます。

7. 空リストのときだけ「データがありません」を出す

Before(JS)

emptyState.hidden = list.children.length > 0;

After(CSS)

.list:not(:has(li)) {
  display: none;
}
.list-wrapper:not(:has(li)) .empty-state {
  display: block;
}
<div class="list-wrapper">
  <ul class="list"><!-- 0件のときは空 --></ul>
  <p class="empty-state">該当するデータがありません</p>
</div>

フィルタリングでJSが li を出し入れするような画面でも、空状態の表示ロジックはCSSに寄せられます。「状態の判定」をDOMの実体そのものに任せられるので、JS側で件数を数える処理とその同期漏れが消えます。

8. 件数でスタイルを変える

:nth-child() と組み合わせると、「何件以上か」で分岐できます。

/* 6件以上ならスクロール領域にする */
.list:has(> li:nth-child(n + 6)) {
  max-block-size: 20rem;
  overflow-y: auto;
}

/* 1件しかないなら中央寄せ */
.grid:has(> :only-child) {
  justify-content: center;
}

li:nth-child(n + 6) は「6番目以降の li」なので、それが存在する=6件以上ある、という判定になります。CSSで数を数えているわけです。


状態・UI編

9. モーダルが開いたら背景のスクロールを止める

body.no-scroll をJSで付けたり外したりしていた定番処理です。

Before(JS)

dialog.addEventListener('close', () => document.body.classList.remove('no-scroll'));
openBtn.addEventListener('click', () => {
  dialog.showModal();
  document.body.classList.add('no-scroll');
});

After(CSS)

html:has(dialog[open]),
html:has([popover]:popover-open) {
  overflow: hidden;
}

<dialog>open 属性、popover:popover-open といったブラウザが自動で管理してくれる状態を、そのままCSSの条件にできます。JSで状態を二重管理していた部分が丸ごと消えるのがポイントで、「閉じたのにクラスが外れず、スクロールできないままになる」という定番バグも同時に消えます。

同じ考え方で、開閉状態に応じた装飾も書けます。

/* ドロワーが開いている間、ヘッダーの背景を暗くする */
body:has(nav [aria-expanded="true"]) header {
  background: rgb(0 0 0 / 0.8);
}

aria-expanded のようなもともとJSが更新する属性をCSSの条件にするのは相性がよく、「状態はHTML属性に1箇所だけ持ち、見た目はCSSが追従する」というきれいな構造になります。

10. チェック済みの項目をグレーアウトする

ToDoリストのようなUIです。

Before(JS)

list.addEventListener('change', (e) => {
  e.target.closest('li').classList.toggle('is-done', e.target.checked);
});

After(CSS)

li:has(input[type="checkbox"]:checked) {
  opacity: 0.5;
}
li:has(input[type="checkbox"]:checked) .title {
  text-decoration: line-through;
}

/* 1件でも選択されていたら一括操作バーを出す */
.list-page:has(input[type="checkbox"]:checked) .bulk-actions {
  display: flex;
}

最後の1行が地味に強力で、「何か選択されているか」をJSで数えずに判定しています。テーブルの一括選択UIでよくある処理が、セレクタ1つになります。


ここから先は :has() の落とし穴

10個並べたので、実際に書くときにハマるところも書いておきます。

特異度は「引数の中で最も強いもの」

:has():is():not() と同じく、引数の中で最も特異度の高いセレクタを自分の特異度に持ち込みます。

/* 特異度 (1, 1, 0) — id が効いてしまう */
.card:has(#special) {  }

/* 特異度 (0, 2, 0) */
.card:has(.badge) {  }

意図せず特異度が跳ね上がると、あとから上書きできなくなります。特異度を上げたくないときは :where() を挟みます。

/* 特異度 (0, 1, 0) — :where() の中身は 0 */
.card:has(:where(#special)) {  }

引数リストは「非寛容(unforgiving)」

:is():where() は中に無効なセレクタがあっても残りが生き残りますが、:has() は違います。1つでも解釈できないセレクタがあると、そのルール全体が捨てられます。

/* ::slotted() は :has() の中では無効 → このルールごと無効になる */
.card:has(img, ::slotted(x)) {  }

同じ理由で、:has() 自体を知らないブラウザは、:has() を含むルールを丸ごと無視します。これは裏を返せばきれいなフォールバックになるということでもあります。

/* :has() 非対応環境ではこちらだけが適用される */
.card { grid-template-columns: 1fr; }

/* 対応環境で上書き */
.card:has(> img) { grid-template-columns: 120px 1fr; }

明示的に分岐したいなら @supports を使います。

@supports selector(:has(*)) {
  /* :has() 前提のレイアウト */
}

書けないもの

  • :has() の入れ子は不可:has(:has(…)) は無効
  • 擬似要素は不可:has(::before)::before:has(…) も無効。擬似要素は「親のスタイル次第で存在が決まる」ため、循環参照になり得るからです

パフォーマンス

かつて「:has() は重い」と言われましたが、現在のブラウザは十分に最適化されています。ただし書き方の原則はあります。

/* ❌ アンカーが広すぎ、内側も子孫全体を探す */
body:has(.is-active) {  }

/* ✅ アンカーを具体的にし、内側は > や + で範囲を絞る */
.card:has(> .badge) {  }

:has() はDOMが変化するたびに再評価されるので、アンカー(:has() の左側)を具体的にする内側は > / + で探索範囲を限定する——この2つを守れば実務で問題になることはまずありません。


JSを消せるが、消すべきではないケース

最後にこれを書かないとこの記事は片手落ちになります。

:has() は強力なので、「チェックボックスハック」の類が復権しがちです。

/* ハンバーガーメニューをJSゼロで開閉 */
body:has(#nav-toggle:checked) .drawer { translate: 0; }

動きます。でも、これはやめたほうがいいです。

チェックボックスは「開閉ボタン」ではありません。支援技術には「チェックボックス、チェック済み」としか伝わらず、メニューが開いたことも、そこに移動できることも伝わりません。本来必要なのは <button aria-expanded> と、状態に応じたフォーカス管理です。

同じことがテーマ切替やタブUIにも言えます。

判断基準:has() で置き換えていいのは「見た目の同期」であって、「状態そのもの」ではない。

状態は引き続きHTMLの属性(aria-expandedopenchecked:user-invalid など、支援技術に意味が伝わるもの)が持ち、CSSはそれを読んで見た目を追従させる。この分担なら、JSは「属性を1つ更新するだけ」まで痩せますし、アクセシビリティも壊れません。

前に書いた aria-labelの記事 と同じ話で、ARIAやセマンティクスを削るためにCSSを使わない、というのが原則です。


まとめ

:has() が消せるJavaScriptは、要するに「DOMを見てクラスを付け替えるだけのコード」です。

  • 入力状態、チェック状態、開閉状態 → ブラウザが持っている状態をそのまま条件にする
  • 子要素の有無、件数 → DOMの実体そのものを条件にする
  • 後続要素との関係 → 先読みで自分のスタイルを決める

このいずれかに当てはまるなら、そのJSはたぶん消せます。そして消えるのは行数だけでなく、「状態の二重管理」というバグの温床です。

一方で、状態の意味を支援技術に伝える役目までCSSに移してはいけない。:has() を入れるときは「これは見た目の同期か、状態そのものか」を一度確認する——それだけ気を付ければ、安心して使える機能です。


参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?