# キーボードでもスクリーンリーダーでも選べない星評価

Source: https://easeweb.dev/learn/star-ratings
Topics: カスタム入力 > 星評価を矢印キーで選べ、いくつ選んだかが伝わる (WCAG 2.1.1, 4.1.2, 1.3.1)
The fix: 星を、設問で名前を付けた一つのラジオボタンのグループとして作り、各星には値で名前を付けます。方法は三つあります。描いた星の上に見た目だけ隠した本物のラジオボタンを重ね、:checkedから星を塗る方法。spanを残し、aria-checked、選んだ星へTabの止まり位置を移すtabindex、矢印キーを備えたARIAのラジオグループにする方法。ラジオボタンを使い、選んだ評価を星の横に言葉でも書く方法です。
Test with: keyboard, screen reader, mouse, forced colors, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ https://www.w3.org/WAI/ARIA/apg/patterns/radio/ https://developer.mozilla.org/en-US/docs/Web/CSS/:has https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/fieldset

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

あるショップのレビューのフォームには、「総合評価」の下に大きな星が5つ並んでいます。灰色の輪郭だけの星で、クリックするとその星まで琥珀色に塗られます。星なら誰でも読み方を知っていますし、プルダウンや数字の列よりも気軽に意見を求められます。開発者は描かれたとおりに、よくある二つの方法のどちらかで作ります。一つは、各星を `span` にしてクリックのハンドラーを付け、その星と手前の星を塗る方法。もう一つは、各星をラジオボタンのラベルにし、ラジオボタンを `display: none` で隠して、チェックされたものから星を塗る方法です。マウスで使う限り、どちらも同じに見えて同じに動きます。4つ目の星をクリックすれば、星が4つ塗られます。

マウスを使わずに、設問からTabを押します。フォーカスは「レビューを投稿」へ直接移り、星には一度も届かず、星を塗れるキーもありません。スクリーンリーダーは「総合評価」、続いて「レビューを投稿、ボタン」と読みます。spanの星は何も読まれません。ラジオボタンを隠した場合は、ラベルに隠した「星1つ」から「星5つ」を5行のただのテキストとして読むだけで、ラジオボタンであることも、どれを選んだかも伝わりません。マウスで4つ目の星をクリックしてから聞き直しても、読み上げは変わりません。評価は、4つ塗られた星という見た目にしかありません。

## 確認できる問題

星は操作部品として描かれていますが、操作部品として作られていません。`span` にはフォーカスが届かず、役割もなく、クリックのハンドラーを付けてもどちらも加わりません。`display: none` のラジオボタンはページから完全に取り除かれ、Tabの順序からもアクセシビリティツリーからも外れます。一方で、ラベルをクリックすればチェックは付くので、マウスではこれまでどおり動き、マウスで確かめる人には問題が見えません。

このため、3つの達成基準を満たしていません。2.1.1 キーボード：クリックでは評価を選べますが、フォーカスできるものが残っていないため、どのキーでも選べません。4.1.2 名前・役割・値：目に見えるのは、一つの値を選ぶ5つの選択肢と、そのうちの選ばれた一つです。ところがソフトウェアには、ラジオボタンも、「星4つ」のような名前も、チェックの状態も見つからないため、スクリーンリーダーは星が何なのかも、どれが選ばれているかも伝えられません。1.3.1 情報及び関係性：星の絵は、「総合評価」が星で答える設問であること、5つの星が一つの選択としてまとまっていること、いくつ選んだかを、一度に示しています。spanの場合、コードでは設問はどこにも結び付かない段落で、星はばらばらのspan、選択はそのうち4つに付いたクラスです。ラジオボタンを隠した場合は、fieldsetが星を設問の下にまとめてはいますが、星を一つの選択にし、どれを選んだかを伝えるラジオボタンが取り除かれています。どちらの場合も、選択は画面の上にしかありません。これは、WCAGの失敗例F15（アクセシビリティAPIを通じて支援技術に伝わらない独自の操作部品）にあたります。

## 利用者への影響

評価を付けるのは、何かを終えたときです。購入後のレビュー、サポートのチャットを終えた後の「対応はいかがでしたか？」、講座やレシピの採点などです。マウスでしか付けられないと、レビューは評価なしになり、ショップが集める評価は、クリックやタップができる人のものだけになります。

## 自動チェックとAIの限界

問題のほとんどは、あるべきものが欠けていることです。axeは表示されているものを調べるので、`display: none` のラジオボタンは調べません。背景に星を描いた空の `span` には、確認する役割もありません。ページにあるものを調べても、問題は見つかりません。EaseWebの診断は、どちらの作り方も見つけます。classやidで評価や星を示す要素の中に、それぞれポインターのカーソルを持つ星が並び、どれにもTabが届かないものを `ew:rating-star-unreachable` として、ラベルは表示されているのに隠れているラジオボタンを、言葉のない星のラベルも含めて `ew:hidden-native-input` として報告します。作り直した星に名前のない `role="radio"` を付けると、axeが `aria-toggle-field-name` を報告します。修正を頼むと、コード生成AIは各spanに `tabindex="0"` を付けることがあります。Tabは5回止まるようになりますが、どのキーでも選べず、ラジオボタンであることも、どれを選んだかも読み上げられません。各spanに `aria-label="星4つ"` を付ける手当てもありますが、spanはその名前を持てず、キーボードも使えないままです。マウスを使わずに、評価を付けてみてください。

## 修正前：onclick付きの5つのspan

```html
<form action="/reviews" method="post">
  <p class="question">総合評価</p>
  <div class="stars">
    <span class="star" onclick="rate(1)"></span>
    <span class="star" onclick="rate(2)"></span>
    <span class="star" onclick="rate(3)"></span>
    <span class="star" onclick="rate(4)"></span>
    <span class="star" onclick="rate(5)"></span>
  </div>
  <input type="hidden" name="rating">
  …
  <button type="submit">レビューを投稿</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
}
/* 空の星：灰色の輪郭、#6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* 塗られた星：琥珀色、#d97706 */
.star.on {
  background-image: url("star-filled.svg");
}
```

```js
function rate(value) {
  document.querySelector('input[name="rating"]').value = value;
  document.querySelectorAll('.star').forEach((star, index) => {
    star.classList.toggle('on', index < value);
  });
}
```

spanが受け付けるのはクリックだけです。Tabの順序になく、役割も名前も状態もなく、評価は、支援技術が読まないクラスと隠しフィールドの値にしかありません。

## 修正前：display: noneで隠したラジオボタン

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">総合評価</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">星1つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">星2つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">星3つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">星4つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">星5つ</span>
    </label>
  </fieldset>
  …
  <button type="submit">レビューを投稿</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* 空の星：灰色の輪郭、#6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
.star input {
  display: none;
}
/* チェックされた星まで塗る：琥珀色、#d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

マークアップはほとんど正しく、設問をlegendにしたグループ、本物のラジオボタン、共通のname、各星の言葉がそろっています。それを `display: none` が台無しにしています。ラベルをクリックすればラジオボタンはチェックされて星が塗られますが、ラジオボタンはTabの順序にもアクセシビリティツリーにもなく、ラベルの言葉は、チェックの状態もない、ただのテキストとして残ります。

## 修正後：描いた星の上にラジオボタンを重ねる

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">総合評価</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">星1つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">星2つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">星3つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">星4つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">星5つ</span>
    </label>
  </fieldset>
  …
  <button type="submit">レビューを投稿</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* 空の星：灰色の輪郭、#6b7280 */
.star {
  position: relative;
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* 見た目だけ隠す：各ラジオボタンが自分の星を覆う */
.star input {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
/* チェックされた星まで塗る：琥珀色、#d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
/* ラジオボタン自体のフォーカスから描く枠 */
.star:has(:focus-visible) {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

デザインは描かれたとおりに残ります。同じ星、同じ塗りで、画面に増えるのはフォーカスの枠だけです。ラジオボタンは取り除かずに透明にし、それぞれを自分の星の上に重ねるので、Tabの順序にもアクセシビリティツリーにも残り、星に触れればそのラジオボタンに届きます。あとはブラウザーが担います。Tabは評価に一度だけ止まり、矢印キーで選択が星一つずつ移り、スクリーンリーダーは設問の「総合評価」に続けて、「星4つ、ラジオボタン、チェックあり、5分の4」のように読み上げます。設問はグループの `legend` で、星はチェックされたラジオボタン自体から塗るので、見た目と読み上げがずれません。ラジオボタンはソースでは1から5の順に並び、チェックされた星より前の星は `:has()` で塗るので、塗りのために順序を逆にする必要もありません。

## 修正後：spanを残し、ARIAのラジオグループにする

```html
<form action="/reviews" method="post">
  <p class="question" id="rating-question">総合評価</p>
  <div class="stars" role="radiogroup" aria-labelledby="rating-question">
    <span class="star" role="radio" aria-checked="false" aria-label="星1つ" tabindex="0" onclick="rate(1)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="星2つ" tabindex="-1" onclick="rate(2)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="星3つ" tabindex="-1" onclick="rate(3)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="星4つ" tabindex="-1" onclick="rate(4)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="星5つ" tabindex="-1" onclick="rate(5)"></span>
  </div>
  <input type="hidden" name="rating">
  …
  <button type="submit">レビューを投稿</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
}
/* 空の星：灰色の輪郭、#6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* チェックされた星まで塗る：琥珀色、#d97706 */
.stars:has([aria-checked="true"]) .star {
  background-image: url("star-filled.svg");
}
.star[aria-checked="true"] ~ .star {
  background-image: url("star.svg");
}
.star:focus-visible {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
```

```js
const stars = [...document.querySelectorAll('.star')];

function rate(value) {
  document.querySelector('input[name="rating"]').value = value;
  stars.forEach((star, index) => {
    const chosen = index === value - 1;
    star.setAttribute('aria-checked', String(chosen));
    star.tabIndex = chosen ? 0 : -1;
  });
  stars[value - 1].focus();
}

// ラジオグループのキー：矢印で選択を移し、端では反対の端へ戻る。Spaceで選ぶ。
const STEP = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
stars.forEach((star, index) => {
  star.addEventListener('keydown', (event) => {
    if (event.key === ' ') rate(index + 1);
    else if (event.key in STEP) rate(((index + STEP[event.key] + 5) % 5) + 1);
    else return;
    event.preventDefault();
  });
});
```

spanと見た目を残し、ラジオボタンなら初めから備わっているものを手で加えます。各星の役割とチェックの状態（`role="radio"` と `aria-checked`）、各星とグループの名前、選んだ星へ移る一つだけのTabの止まり位置（移動する `tabindex`）、そして矢印キーです。塗りはクラスではなく `aria-checked` から描くので、読み上げられない評価を星が示すことはありません。W3Cの星評価の例と同じ作りで、コンポーネントライブラリのSVGアイコンのように、星をスクリプトが描く要素のまま残す必要があるときに向きます。どの部分も欠かせません。キーを省けば、Tabは一つの星に止まっても、どのキーでも変えられません。`aria-checked` を省けば、スクリーンリーダーには、どれも選ばれていない5つのラジオボタンとして伝わります。

## 修正後：ラジオボタンに、評価を言葉で添える

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">総合評価</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">星1つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">星2つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">星3つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">星4つ</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">星5つ</span>
    </label>
    <span class="score">未評価</span>
  </fieldset>
  …
  <button type="submit">レビューを投稿</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* 空の星：灰色の輪郭、#6b7280 */
.star {
  position: relative;
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* 見た目だけ隠す：各ラジオボタンが自分の星を覆う */
.star input {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
/* チェックされた星まで塗る：琥珀色、#d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
/* ラジオボタン自体のフォーカスから描く枠 */
.star:has(:focus-visible) {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
/* 選んだ評価を星の横に言葉で：#4b5563、7.56:1 */
.score {
  align-self: center;
  margin-inline-start: 0.75rem;
  color: #4b5563;
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

```js
const WORDS = ['不満', 'やや不満', 'ふつう', '満足', 'とても満足'];

document.querySelector('.stars').addEventListener('change', (event) => {
  const value = Number(event.target.value);
  document.querySelector('.score').textContent = `5段階中${value}：${WORDS[value - 1]}`;
});
```

ラジオボタンは一つ目の修正と同じで、3つの達成基準を満たすのはこのラジオボタンです。変わるのは何もしていないときの見た目で、星の横に「未評価」という1行の言葉が加わり、選択が移ると「5段階中4：満足」のように変わります。星とその大きさ、色は描いたとおりです。言葉で書けば、塗られた星を数える人に限らず、フォームを読むすべての人に評価が伝わります。塗られた星と空の星をひと目で見分けにくい人にも、拡大鏡で星を2つずつしか見ていない人にも、このサイトで星4つが何を意味するのか迷う人にも、評価が言葉で届きます。この行はライブリージョンにはしません。選択が移るたびに、スクリーンリーダーはすでに「星4つ、ラジオボタン、チェックあり」のように読み上げますし、フォームを読み進めれば、この言葉も読まれます。

## どの方法を選ぶか

三つとも適合します。描いた星の上にラジオボタンを重ねる方法は、デザインを描いたとおりに残し、スクリプトも要らず、キー、フォーカス、フォームの値、タッチでの位置をブラウザーに任せられます。星をラベルにできるなら、これを使います。ARIAのラジオグループは、スクリプトやコンポーネントが描く要素を残せますが、キー、フォーカス、状態を自分で書いて確かめることになります。星をラベルにできないときだけ使います。評価を言葉でも星の横に書くかどうかは、デザインで決めることです。フォームに1行加わる代わりに、星を数えられない人、数えたくない人にも評価が伝わります。デスクトップだけでなくスマートフォンでも言葉を置く場所があるか、デザインファイルの上で決めてください。

## 実装の判断

CSSだけで作る定番の星評価は、ラジオボタンを5から1の順に書き、`flex-direction: row-reverse` で並びを逆にします。`input:checked ~ label` で塗れる、チェックされた星より後ろの星を、画面では手前に見せるためです。これはまねしないでください。スクリーンリーダーは「星5つ」から読み、矢印キーは押した矢印と逆の向きに選択を移します。修正のように `:has()` を使えば、ソースの順序のまま、チェックされた星より前の星を塗れます。

ラジオボタンはチェックを外せません。評価を空のままにできるなら、星の後に「評価を取り消す」ボタンなどの戻り道が必要です。スクリプトでグループのチェックを外し、フォーカスを最初の星へ戻します。評価が必須なら、ラジオボタンに `required` を付けられます。フォームを送信したとき、ブラウザーがグループへフォーカスを移し、何が足りないかを伝えます。

ポインターを星の上で動かすと、評価をプレビューとして見せるデザインも多くあります。ラジオボタンがあれば、`:hover` だけで描くこのプレビューは問題になりません。矢印キーは移るたびに星をチェックするので、キーボードの利用者にはプレビューではなく実際の選択が見えます。半分の星を使うなら、半分ごとに一つ、合わせて10個のラジオボタンが必要です。名前は「星半分」から「星5つ」とし、各星を半分ずつ描き分けます。ポインターが星のどちらの半分にあるかをスクリプトで読む方法では、キーボードで半分を選ぶ手段がありません。

Windowsのコントラスト テーマでは、ブラウザーが色を利用者の配色に置き換えますが、背景画像は残すので、星は琥珀色と灰色のまま表示され、輪郭の星と塗られた星を見分けられます。フォーカスの枠は `outline` で描いており、ブラウザーがテーマの色で描き直します。SVGの `fill` を固定の色で描いた星は、暗いテーマで背景に溶けることがあります。W3Cの例のように `currentColor` で描いてください。

ほかの方法で隠したチェックボックスとラジオボタン、そして `appearance: none` でinput自体に描く方法など、そのほかの修正は[キーボードでもスクリーンリーダーでも使えないカスタムチェックボックス](/ja/learn/custom-checkboxes)で扱います。商品名の横の平均評価のように、付けた後に表示する評価は操作部品ではありません。「5段階中4」のように評価を言葉で伝える必要があります。その方法は[読み上げられないことが多い、divやspanのaria-label](/ja/learn/aria-label-on-generic)で示しています。

## 修正の確認

1. マウスを使わずに操作する（MacのSafariでは、先に設定の「詳細」で「Tabキーを押したときにウェブページ上の各項目を強調表示」をオンにする。オフのままだと、Tabがどのサイトでもラジオボタンを飛ばすことがある）。Tabで評価へ移動し、選んだ星か、何も選んでいなければ最初の星に、見えるフォーカスの枠とともに一度だけ止まることを確認する。矢印キーで選択が星一つずつ移り、塗られた星がそれに合わせて変わることを確認する。
2. キーボードで評価を選び、キーボードでフォームを送信して、送信された評価が星の示す評価と同じであることを確認する。
3. スクリーンリーダーで星へ移動し、設問、続いて各星の値、役割、選ばれているかどうかが読み上げられ、星を離れて戻ったときに、選んだ評価がもう一度読み上げられることを確認する。
4. Windowsのコントラスト テーマ、または強制配色のエミュレーションで、選んだ星と空の星を見分けられ、フォーカスの枠が見えることを確認する。
5. スマートフォンでスクリーンリーダーを有効にし、各星に触れて、そこでラジオボタンが見つかり、ダブルタップで選べることを確認する。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。評価のコンポーネントやテーマを変更した際は、再度確認します。

## 制限

このサンプルは、どのアプリケーションでも同じ結果になることを保証するものではありません。CSSは最小限の例なので、星の大きさ、コントラスト、フォーカスの枠を実際の配色で確認してください。同じ形の灰色の星と黄色の星のように、色だけで見分ける星は別の問題で、色だけに頼る表示のトピックで扱います。そのガイドは準備中です。設問が結び付いていないラジオボタンのグループは[設問名がないラジオボタンとチェックボックスのグループ](/ja/learn/field-groups)で扱います。レビューのフォーム全体を、キーボード、スクリーンリーダー、スマートフォンで確認してください。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
