CSSだけで星を"半押し"可能な10段階レーティング機能を実装する(翻訳)
実験と調査、そしてアホなAIとのお付き合いを経て、やっとのことで「ラジオボタンとラベルだけを使って、星を"半押し"可能な」10段階レーティングコンポーネントをクリーンかつシンプルに実装できました。50行の美しいCSSです。
それでは順に見ていきましょう。
コードを解説する前に、他の人たちはこの問題にどうやってCSSだけで取り組んできたのかを調べてみたところ、以下の2つが良さげでした。
参考: A star rating widget in CSS
どちらもシンプルなradioボタンとラベルで構成されていて、私の用件を満たすうえで不可欠でしたが、惜しいことにどちらも一長一短でした。
1つ目のほうは星の"半押し"に対応しておらず、2つ目のCodePenの方はFontAwesomeのフォントに依存しています。
私はシンプルなSVG背景画像とラジオボタンで実装したかったので、これらのソリューションを昇華した上で、独自の実装を試してみました。
重要な詳細事項がいくつかありますので、1つずつ見ていくことにしましょう。
詳細その1はHTMLに関連します。
ここでは素のCSSだけを使いたいので、利用できるCSS機能に若干の制約が生じます。マウスをホバーした要素の「直後」にある要素を選択するのであれば、~による後続の兄弟セレクタが使えます。
しかし、星によるレーティングコンポーネントでは、現在マウスをホバーしている星が点灯している場合に次はどの星を点灯するかを示すために、マウスをホバーした要素の(次ではなく)「直前」の要素を強調表示する必要があります。
この振る舞いを実現するために、HTML構造で星を表す10個のラジオボタンを、最大の5から最小の0.5まで「逆順」で並べることにしました。
<fieldset class="star-rating">
<input type="radio" id="rating10" name="rating" value="10" />
<label for="rating10" title="5 stars" aria-label="5 stars"></label>
<input type="radio" id="rating9" name="rating" value="9" />
<label for="rating9" title="4 1/2 stars" aria-label="4 1/2 stars"></label>
<input type="radio" id="rating8" name="rating" value="8" />
<label for="rating8" title="4 stars" aria-label="4 stars"></label>
<input type="radio" id="rating7" name="rating" value="7" />
<label for="rating7" title="3 1/2 stars" aria-label="3 1/2 stars"></label>
<input type="radio" id="rating6" name="rating" value="6" />
<label for="rating6" title="3 stars" aria-label="3 stars"></label>
<input type="radio" id="rating5" name="rating" value="5" />
<label for="rating5" title="2 1/2 stars" aria-label="2 1/2 stars"></label>
<input type="radio" id="rating4" name="rating" value="4" />
<label for="rating4" title="2 stars" aria-label="2 stars"></label>
<input type="radio" id="rating3" name="rating" value="3" />
<label for="rating3" title="1 1/2 stars" aria-label="1 1/2 stars"></label>
<input type="radio" id="rating2" name="rating" value="2" />
<label for="rating2" title="1 star" aria-label="1 star"></label>
<input type="radio" id="rating1" name="rating" value="1" />
<label for="rating1" title="1/2 star" aria-label="1/2 star"></label>
</fieldset>
これで、現在点灯している星以下の星をすべて点灯するCSSを以下のように楽に書けます。
.star-rating {
display: inline-flex;
flex-direction: row-reverse;
justify-content: flex-end;
}
たとえば、10個のラジオボタンのうちrating9がオンになっているとすると、DOM上ではそれ以下のすべてのラジオボタン(つまりrating8からrating1まで)の星が金色に点灯します。
しかし、星のラジオボタンを最高点から順に最低点まで順に並べると、コンポーネントで星が金色にレンダリングされる順序が逆になってしまいます。ユーザーは、最初は0.5点(星半分)、次は1点(星1つ)、1.5点(星1.5個)、といった具合に星が点灯することを期待するので、星をDOM上で逆順にレンダリングする必要があります。
ありがたいことに、CSSで提供されているflexレイアウトを使えば、flex-directionで要素を簡単に逆順にできます。
.star-rating {
display: inline-flex;
flex-direction: row-reverse;
justify-content: flex-end;
}
.rateコンテナにflex-direction: row-reverseを指定してFlexコンテナにすると、DOMの順序を変更せずに星を逆順に表示できます。
DOM上の順序をCSSセレクタに合わせて最適化し、UIの表示順を用途に合わせて最適化するというこの手法は、CSSの道具箱にぜひ常備しておきたいツールです。
詳細その2は、星のレンダリング方法です。
星の"半押し"をサポートすると、レーティングコンポーネントは著しく複雑になります。実装をシンプルに保つために、FontAwesomeの半分になった星のアイコンを元に、右半分と左半分だけの星のCSVをそれぞれパディングなしで作成しました。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512">
<path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512">
<path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/>
</svg>
続いて、2つのSVGをCSSのbackground: urlに取り込んで、ラベルごとに適切な星画像を表示するようにします。
/* 完全な星のステップ: 右半分だけの星 */
label:nth-of-type(odd) {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/></svg>') no-repeat;
}
/* 完全な星のステップ: 左半分だけの星 */
label:nth-of-type(even) {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/></svg>') no-repeat;
}
labelには星を表示するので、代わりにinputを非表示にします。
input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
続いて、labelにCSSでスタイルを設定して、星の背景SVG画像が適切にレンダリングされるようにします。
label {
display: block;
height: 2rem;
width: 1rem;
}
このとき、widthをheightの「半分」にしておくことが肝心です。
input要素は非表示になっているので(なおスクリーンリーダーではDOMから引き続きアクセスできます)、label要素で半分の星画像をレンダリングすれば、コンポーネントの基礎工事が完成します。
詳細その3は、マウスで星をホバーしたら星のセグメントを点灯させることです。
残念ながら、この振る舞いは、CSSのbackgroundプロパティにSVGをurlに埋め込んだ形では実現できません。CSSでは、マウスを星にかざしてもSVG背景画像の色のfillを動的に変更できないのです。
ありがたいことに、こちらの記事のテクニックを応用して、backgroundプロパティの代わりにmaskプロパティを使えば、background-colorの背景が透けるようにできます。
そこで、まずCSSのlabelに以下のようにbackground-color: currentColor;を追加します。
label {
display: block;
height: 2rem;
width: 1rem;
background-color: currentColor;
}
/* 完全な星のステップ: 右半分だけの星 */
label:nth-of-type(odd) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/></svg>') no-repeat;
}
/* 完全な星のステップ: 左半分だけの星 */
label:nth-of-type(even) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M264 0c-12.2.1-23.3 7-28.6 18L171 150.3 27.4 171.5c-12 1.8-22 10.2-25.7 21.7-3.7 11.5-.7 24.2 7.9 32.7L113.8 329 89.2 474.7c-2 12 3 24.2 12.9 31.3 9.9 7.1 23 8 33.8 2.3L264 439.8V0Z"/></svg>') no-repeat;
}
これで、星にマウスをかざしたときにbackground-colorをシンプルに変更すれば、星のセグメントを点灯できるようになります。
/* チェックされている現在の星とそれ以下の星に色をつける */
input:checked ~ label,
/* マウスオーバーしたときにそれ以下の星に色をつける */
label:hover, label:hover ~ label {
background-color: goldenrod;
}
同様に、checked状態についても星に適切なスタイルが適用されるようにします。
/* 現在の星とそれ以下の星を点灯させる */
input:checked + label:hover, input:checked ~ label:hover,
/* highlight previous selected stars for new rating */
input:checked ~ label:hover ~ label,
/* 選択したそれ以下の星を点灯させる */
label:hover ~ input:checked ~ label {
background-color: gold;
}
これで、以下のように星がいい感じにインタラクティブになります。
詳細その4は、星と星の間隔を少し広げることですが、このときにマウス操作が自然でスムーズになるよう調整します。
最初に思いついたのは、以下のように完全な星のステップ要素にmarginを追加する方法でした。
/* 完全な星のステップ: 右半分だけの星 */
label:nth-of-type(odd) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/></svg>') no-repeat;
margin-inline-end: 0.25em;
}
しかしやってみると、以下のようにマウスが星と星の「間に」さしかかると、左側の星の明かりが全部消えてしまい、ぎくしゃくしてしまいます。
これではユーザーエクスペリエンスが台無しです。
ここでやりたいのは、星と星の間の間隔は広げつつ、マウスが星と星の間にさしかかったときにもマウスが「技術的に」星のセグメント上にあるかのようにすることです。
これは、星のサイズにマージンを加えたものと同じサイズを持つ擬似要素を別途追加することで修正できます。
label:nth-of-type(odd) {
mask: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 512"><path d="M0 0c12.2.1 23.3 7 28.6 18L93 150.3l143.6 21.2c12 1.8 22 10.2 25.7 21.7 3.7 11.5.7 24.2-7.9 32.7L150.2 329l24.6 145.7c2 12-3 24.2-12.9 31.3-9.9 7.1-23 8-33.8 2.3L0 439.8V0Z"/></svg>') no-repeat;
margin-inline-end: 0.25em;
&::after {
content: "";
display: block;
height: 2rem;
width: 1.25rem;
}
}
ここで大事なのは、:after擬似要素のwidthを、星の幅とmargin-inline-endを足したwithと同じにすることです。ここでは手動で値を設定しましたが、CSSプロパティとcalc()関数でwidthを自動算出する方法も使えます。
どちらの場合も、この:after擬似要素を作成することで、星の要素に属する透明なエリアを使って星と星の間のギャップを埋めることが可能です。これにより、マウスを星にかざしたときにがたつかずに反応するようになり、ユーザーエクスペリエンスもスムーズになります。
今度こそ最後の詳細5です。コンポーネントの右側に残っている最後のマージンを削除します。
label:first-of-type {
margin-inline-end: 0;
}
思い出していただきたいのですが、ここでfirst-of-typeセレクタを使っている理由は、画面上の一番右の星が実はDOM上では「最初の」星だからです。
これで、star-ratingコンポーネントの幅は5つの星の幅と完全に同じになりました。なお、fieldset要素をラッピング要素として使っている場合は、そのborderも取り除いておきましょう。
fieldset {
border: none;
}
これでとうとう、素のCSSだけで作った10段階評価のレーティングコンポーネントが完成しました。動作は完全で使いやすく、ユーザー操作に適切に反応してくれます。星と星の間のがたつきも解消されたので、ユーザーエクスペリエンスも快適です。
コード全体を見たい方は、以下のPlaygroundをご覧ください。本記事を気にっていただけましたら、ぜひXで@fractaledmindをフォローしてください。





概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。