🌟

【CSS】:has()を使用したモダンなカスタムラジオボタンのスタイリング

2023/10/01に公開

はじめに

今回は擬似クラス:has()を使用したカスタムラジオボタンコンポーネントのスタイリングについて、そのメリットと共に紹介していきます。

:has()を使ったラジオボタンコンポーネントのサンプルコード

そもそも:has()とは

概要

簡単に:has()の概要をおさらいします。もう知ってるよ!という方はかっ飛ばしてください🏎³₃

https://developer.mozilla.org/ja/docs/Web/CSS/:has

:has() は CSS の擬似クラスで、引数として渡されたセレクターに (指定された要素の :scope の相対で) 該当する要素が一つ以上の要素に一致することを表します。

MDNではこのように記載がありますが、簡単にいうと、:has(引数)の引数に渡された要素があった時にスタイルを当てる、ということができるプロパティです。

サンプルコード

h2:has(span){
  color: red:
}

上の例は、子要素にspanを持っているh2の文字を赤色にする、というものです。HTMLとしては以下です。

<!-- スタイルが当たる -->
<h2><span>見出し</span></h2>
<!-- スタイルが当たらない -->
<h2>見出し</h2>

これを使うとさらにこんなこともできます。

div:has(> input:disabled){
  background: #eee;
}

これは非活性(disabled)のinput要素を直接子にもつdivの背景色を#eeeに変更するコードです。

このように、:has()を使うことでなんと、子要素の状態によって親要素のスタイルを制御できるようになるんです✨
今回ご紹介するカスタムラジオボタンのスタイリングはこの方法を応用して実装します。

ケーススタディ

以下のようなデザインのラジオボタンコンポーネントのケースを考えてみましょう。


通常時


選択時


キーボードのTABキーによるフォーカス時


非活性時

このようなラジオボタンのデザインの場合、枠内全てがラジオボタンのクリッカブルエリアとなるので、マークアップは以下のように実装できます。

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>

ボタン本体のスタイリング

input要素はカスタムしたものに変更したいので以下のように設定します。

.c-radio__input {
  position: absolute;
  appearance: none;
}

かろうじてdisplay:none;にはしないけど表面的には見えないようにする、という感じですね。

ラジオボタンのボタン本体は.c-radio__text::before::afterでスタイリングします。細かいスタイリング方法は省略します。本記事冒頭のCodepenのサンプルコードを参照ください!
ということで今回はセレクタの指定方法だけ記載します。

.c-radio__inputの隣接要素の.c-radio__textを使ってラジオボタンをスタイリングするため、ボタンチェック後のラジオボタン本体のCSSとしては以下のように実装できます。

.c-radio__input:checked + .c-radio__text::after {
  /* チェック後のラジオボタンの内側のスタイル */
}

結論何が言いたいかというと、デフォルトのinput[type="radio"]のUIは使わずに、カスタムでラジオボタンを実装するときに隣接要素としてセレクタを指定するというのが現在よく見る実装パターンということになります。

背景の塗りつぶし

続いて背景の塗りつぶしについて考えてみましょう。

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>

このような構造において、labelの子要素のinputがcheckedになったときに親要素のlabelの背景を変えることは通常のCSSではできないため、別の実装手段を考える必要があります。

方法1: JavaScriptを書く

今回のケースのようにラジオボタンを独立したコンポーネントとして実装する場合、ラジオボタンにJavaScriptでハンドリングする用のクラスなど(下記のjs-radioのようなもの)を用意して、それをもとにJavaScriptで、「.js-radioがcheckedになったとき、その親要素に--checkedなどのmodifireのようなクラスを動的に追加したり削除したりする」というトグルのスクリプトを書く、という方法があります。

<!-- 初期状態 -->
<label class="c-radio">
  <input type="radio" class="c-radio__input js-radio" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>

<!-- checkがついた状態(動的に'--checked'クラスを追加) -->
<label class="c-radio --checked">
  <input type="radio" class="c-radio__input js-radio" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>

cssは以下のように設定しておくことで、チェックがついたときに背景色が変わるようになります。

.c-radio {
  background: white;
  /* その他CSSは略 */
}

.c-radio.--checked {
  background: red;
}

ただ欠点としては、このクラスのトグルを行うためにスクリプトを組まなければならないという点、そしてその工数がかかること、disabledfocusなどの擬似クラスそれぞれによって背景などのスタイルが変わる際はそれぞれの状態でクラスをトグルさせるスクリプトを作成する必要があることが挙げられます。

方法2: 背景色はlabel要素に設定しない

label要素の.c-radioに背景色を設定せず、input要素と隣接要素となるものに背景色を設定することで、問題は解決できます。

まず、HTMLを以下のように修正します。(.c-radio__innerを追加)

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__inner">
    <span class="c-radio__text">オプション1</span>
  </span>
</label>

ここで、.c-radio__innerの要素にdisplay: block;を設定することで、label内全体の背景色が変わる感じでスタイリングする、という方法があります。

/* デフォルトの背景は白色としたとき */
.c-radio__inner {
  display: block;
  background: white;
}

/* ラジオボタンにチェックがついた時に背景を赤色にする */
.c-radio__input:checked + .c-radio__inner {
  background: red;
}

label要素の.c-radioに背景色を設定せず、inputと隣接要素となるものに背景色を設定する

このパターンにおける実装のCSSの全体感としては以下のようになります。

.c-radio__input:checked + .c-radio__inner {
  /* ラジオボタンがチェックされた時の背景のスタイリング (略) */
}
.c-radio__input:focus-visible + .c-radio__inner {
  /* キーボードのTABキーでのフォーカスが当たった時のスタイリング (略) */
}
.c-radio__input:disabled + .c-radio__inner {
  /* ラジオボタンが非活性時の背景のスタイリング (略) */
}
.c-radio__text {
  /* ラジオボタンのテキストのスタイリング (略) */
}
.c-radio__text::before {
  /* カスタムラジオボタンの枠のスタイリング (略) */
}
.c-radio__input:checked + .c-radio__inner > .c-radio__text::after {
  /* カスタムラジオボタンがチェックされたときの中心部分のスタイリング (略) */
}
.c-radio__input {
  /* デフォルトのラジオボタンUIのスタイリング (略) */
}

このようにして、問題を解決することはできますが、こちらも欠点があります。

  • マークアップ構造がシンプルでない
  • 内側から押し広げて色を塗るのが少し気持ち悪い。(どうせならlabel要素に塗りたい)
  • 隣接セレクタなどを使うので、詳細度が高くなりやすい。
  • コードの可読性、見通しが悪くなる。(Sassも同様)

ケーススタディまとめ

これらをまとめると、ケースで挙げたデザインを実装する場合、現状はJavaScriptを使う、または若干複雑なセレクタ指定による実装ということが考えられます。(可読性↓↓)

可読性が下がるということは必然と保守コストも上がるので、結果的にメンテナビリティも下がってしまいます。

Reactなどのフレームワークを使えば動的なCSSクラスのトグルもすぐに実装できますが、そうでない場合はできればCSSだけで実装したいところです。

:has()を使った実装方法

これらのごちゃごちゃした問題を、:has()を使うことで簡潔に記述することができます。

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>
.css
.c-radio {
  /* ラジオボタンのデフォルトの背景などのスタイリング (略) */
}
.c-radio__text {
  /* ラジオボタンのテキストのスタイリング (略) */
}
.c-radio:has(input:checked) {
  /* ラジオボタンがチェックされた時のlabel要素のスタイリング (略) */
}
.c-radio:has(input:focus-visible) {
  /* キーボードのTABキーでのフォーカスが当たった時のlabel要素のスタイリング (略) */
}
.c-radio:has(input:disabled) {
  /* ラジオボタンが非活性時のlabel要素のスタイリング (略) */
}
.c-radio::before {
  /* カスタムラジオボタンの枠のスタイリング (略) */
}
.c-radio:has(input:checked)::after {
  /* カスタムラジオボタンがチェックされたときの中心部分のスタイリング (略) */
}
.c-radio__input {
  /* デフォルトのラジオボタンのスタイリング (略) */
}

カスタムラジオボタン、そしてラジオボタンの状態による親要素のlabelの背景色の変更など、全てのラジオボタンの機能別のスタイリングをinputの親要素の.c-radioに紐づけて実装することができます。

こうすることで、従来の実装方法よりもコードの見通しがよくなり、可読性も上がります。また、カスタムラジオボタンを実装するとキーボードのTABキーによるフォーカスが当たらなかったりする(実際には当たっているが見えない)アクセシビリティ問題も容易に解決できます。

Codepenのサンプル
こちらが今回の:has()を使って実装してみたCodepenのサンプルです。

詳細度比較(考察)

:has()の有無による詳細度の比較

ラジオボタンにチェック(checked)がついたときのクリッカブルエリア(label)の背景色の変更という機能において、:has()の有無によってどのようにセレクタの詳細度が変わるのかを考えてみます。

今回はJavaScriptを使わない実装方法との比較を行います。

:has()を使わないでc-radio__inputの隣接要素.c-radio__innerの背景色を変更する場合

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__inner">
    <span class="c-radio__text">オプション1</span>
  </span>
</label>
.c-radio__input:checked + .c-radio__inner {
  background: red;
}

続いて:has()を使った場合

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__text">オプション1</span>
</label>
.c-radio:has(input:checked) {
  background: red;
}

cssのところだけを抜粋して比較すると、

/* :has()を使わない */
.c-radio__input:checked + .c-radio__inner {
  background: red;
}

/* :has()を使う */
.c-radio:has(input:checked) {
  background: red;
}

これらの詳細度を比較すると、

  • has()なし → id:0, class,擬似class: 3, 要素セレクタ,擬似要素: 0 の 0-3-0
  • :has()あり → id:0, class,擬似class: 2, 要素セレクタ,擬似要素: 1 の 0-2-1

ということで、:has()を使うことで詳細度を少しだけ減らせる可能性があることが期待できます。

ですがここで疑問として出てきそうなのが、「:has()なし」と同様にセレクタを指定するのであれば、以下のような指定で比較すべきではないのか?ということについて。

/* :has()なし */
.c-radio__input:checked + .c-radio__inner {
  background: red;
}

/* :has()なしと比べるとinputのところのセレクタが異なる */
.c-radio:has(input:checked) {
  background: red;
}
↓
↓
/* :has()なしとセレクタ指定の条件を合わせるならこっち? */
.c-radio:has(.c-radio__input:checked) {
  background: red;
}

今回のように、ラジオボタンコンポーネントという独立したUIパーツにおいて、.c-radioの子要素にはinput要素は一つしかないと考えて良いでしょう。となると、わざわざ詳細度を上げてまで.c-radio:has(.c-radio__input:checked)と、input要素をそのクラスで指定する必要性はあまりなく、.c-radio:has(input:checked)でも良さそうと考えています。(コンポーネント内部にinput要素が複数あるなどの条件は別です。)

文章で言い換えると、「.c-radioの子要素の.c-radio__inputがチェックになってるとき」ではなく、:has()を使った場合は「.c-radioの子要素のinput要素がチェックになってるとき」でよさそうといった感じです。

逆はOK?

<label class="c-radio">
  <input type="radio" class="c-radio__input" name="sample" value="1">
  <span class="c-radio__inner">
    <span class="c-radio__text">オプション1</span>
  </span>
</label>
/* :has()なし */
.c-radio__input:checked + .c-radio__inner {
  background: red;
}
↓
↓
/* :has()ありのセレクタ指定方法に合わせると... */
input:checked + .c-radio__inner {
  background: red;
}

この指定方法だと、以下の欠点があります。

  • input:checkedという指定の仕方だと、コンポーネント単位でフロントエンドを構築していく局面において、他の機能を持ったラジオボタンがある場合や、チェックボックスのコンポーネントがある場合も、checked時にこのinput:checkedに該当するため、スタイルの影響範囲が特定しづらくなり、不明瞭なりやすいため、望ましい設計ではありません。

詳細度比較まとめ

/* :has()なし */
.c-radio__input:checked + .c-radio__inner {
  background: red;
}

/* :has()あり */
.c-radio:has(input:checked) {
  background: red;
}

よって、:has()なしの実装では.c-radio__input:checkedとしているところを、:has()を使う際にはinput:checkedでも良さそうというのは、:has()という擬似クラスが、擬似クラスを付与した要素に対して引数の要素を相対的に捉えることができるという利点があるからだと思いました。

ということで、:has()を使うと、独立したラジオボタンやチェックボックスなどのUIコンポーネントの実装において現在よくある実装方法よりも詳細度を下げられることが期待できそうだと思いました。

:has()の欠点


MDN :has()のブラウザ互換性

Firefoxで対応していない(2023/09現在)
https://developer.mozilla.org/ja/docs/Web/CSS/:has#ブラウザーの互換性

おそらくこれが実際の開発でまだ使いづらい:has()の最大の欠点でしょう。

State of CSS 2023のブラウザの非互換性のアンケート結果

State of CSS 2023のブラウザの非互換性による使いづらいCSSプロパティのアンケート結果にもある通り、使いづらいプロパティで:has()が堂々の1位をとっているのもおそらくこれが起因しているのかもしれませんね。🌱

https://2023.stateofcss.com/en-US/usage/#css_interoperability_features

朗報

https://www.mozilla.org/en-US/firefox/120.0a1/releasenotes/

2023/9/25、Firefox Nightly Version 120.0a1でついに:has()が使えるようになりました!
なので通常のFirefoxでの対応も時間の問題だと思われます✨楽しみですね!!

ただFirefox対応後に全ブラウザで:has()が対応しても、プロダクトの正常動作を保証するブラウザのバージョン(Firefox ver.XXXまで対応、など)を考慮すると、安心して開発で使えるようになるのは少し先になりそうです。

おわりに

:has()は今までのCSSではできなかった、子要素の有無や状態によって親要素のスタイルをハンドリングすることができるとっても便利なプロパティなので安心して使えるようになったら積極的に使っていきたいですね!

Discussion