Reactで、星の数で評価する入力欄を作る方法
星の数で評価する入力欄は、星1つごとに <input type="radio"> を置き、radio ボタンそのものは見えないように隠して、label の中の「★」を見せる形で作れます。矢印キーで選ぶ操作、フォームで送られる値、required での必須の確認はブラウザに任せられるので、React 側で持つのは「選んだ星の数」と「マウスを乗せている星」の2つの state だけです。
ライブラリを使わずに StarRating という部品を作り、マウス、キーボード、フォームの送信での動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。スクリーンリーダーでの読み上げ、タッチ操作、Firefox と Safari は試していません。読み上げに使われる情報は、Playwright でアクセシビリティツリーを書き出して確かめています。

星1つを、radio ボタン1つにする
StarRating は、フォームで送るときの名前(name)、入力欄の見出し(label)、星の数(max)などを受け取ります。全体を fieldset で囲み、legend に見出しを書きます。星は1つずつ label で包み、その中に radio ボタンと「★」を入れます。
import { useState } from 'react';
type Props = {
name: string;
label: string;
max?: number;
defaultValue?: number;
required?: boolean;
onChange?: (value: number) => void;
};
export function StarRating({ name, label, max = 5, defaultValue = 0, required, onChange }: Props) {
const [value, setValue] = useState(defaultValue);
// マウスを乗せている星の番号(乗せていないときは 0)
const [hover, setHover] = useState(0);
const shown = hover || value;
return (
<fieldset className="star-rating" onMouseLeave={() => setHover(0)}>
<legend>{label}</legend>
{Array.from({ length: max }, (_, i) => {
const n = i + 1;
return (
<label key={n} className={n <= shown ? 'star is-on' : 'star'} onMouseEnter={() => setHover(n)}>
<input
type="radio"
className="visually-hidden"
name={name}
value={n}
checked={value === n}
required={required}
onChange={() => {
setValue(n);
onChange?.(n);
}}
/>
<span aria-hidden="true">★</span>
<span className="visually-hidden">星{n}つ</span>
</label>
);
})}
</fieldset>
);
}
radio ボタンは、すべてに同じ name を付けると1つのグループになり、どれかを選ぶとほかの選択が外れます。この仕組みは MDN の radio ボタンの解説にあります。星の数を「どれか1つを選ぶ」入力として扱えるので、選んだ星までを点灯させる処理だけを React で書けば済みます。
checked に state との比較を渡しているので、この radio ボタンは React が値を管理する「制御されたコンポーネント」です。React の公式リファレンスにある input の説明のとおり、checked を渡すときは、state を更新する onChange も一緒に渡します。
「★」の文字は aria-hidden="true" で読み上げの対象から外し、代わりに見えない文字で「星3つ」のような名前を付けています。Playwright でアクセシビリティツリーを書き出すと、次のように「この記事の評価」というグループの中に、名前の付いた radio ボタンが5つ並びました。
- group "この記事の評価":
- text: この記事の評価
- radio "星1つ"
- text: 星1つ
- radio "星2つ"
- text: 星2つ
- radio "星3つ"
- text: 星3つ
- radio "星4つ"
- text: 星4つ
- radio "星5つ"
- text: 星5つ
radio ボタンは display: none で隠さない
radio ボタンの丸を見せないためのクラス .visually-hidden は、大きさを1pxにして切り抜く書き方です。画面からは消えますが、要素は残るので、フォーカスを受け取れ、アクセシビリティツリーにも残ります。見た目を整える行の一部(body と legend の余白)は省いています。
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.star-rating {
display: flex;
gap: 4px;
border: none;
padding: 0;
margin: 0;
}
.star {
position: relative;
font-size: 40px;
line-height: 1;
color: #c8d0d4;
cursor: pointer;
}
.star.is-on {
color: #f5b301;
}
.star:has(:focus-visible) {
outline: 3px solid #067a98;
outline-offset: 2px;
border-radius: 4px;
}
比べるために、radio ボタンを display: none で隠した版も試しました。4つ目の星をクリックすると、値は4になり、マウスでは問題なく選べます。ところが、アクセシビリティツリーからは radio ボタンが消え、グループの中は文字だけになりました。Tab キーを押すと、星を飛ばして送信ボタンにフォーカスが移りました。
- group "この記事の評価": この記事の評価 星1つ 星2つ 星3つ 星4つ 星5つ
MDN の display の説明にも、display: none の要素はアクセシビリティツリーから取り除かれ、スクリーンリーダーで読み上げられなくなると書かれています。
display: none で隠すと、マウスで試しているかぎりは普通に動くため、キーボードで選べないことに気づきにくくなります。radio ボタンを隠すときは、上の .visually-hidden のように、要素を残したまま見えなくする書き方にしてください。
マウスを乗せている間だけ、星の数を変えて見せる
点灯させる星の数は、hover || value で決めています。マウスを乗せている星があればその番号、なければ選んだ値です。label の onMouseEnter で番号を覚え、fieldset の onMouseLeave で 0 に戻します。
検証環境で試したところ、何も選んでいない状態で4つ目に乗せると星は4つ点灯し、マウスを外すと0に戻りました。3つ目をクリックしたあとに5つ目へ乗せると5つ点灯し、外すと選んだ3に戻りました。乗せている間も、選ばれている radio ボタンは3のままです。操作ごとの結果を表にまとめます。
| 操作 | 点灯している星の数 | 選ばれている星 |
|---|---|---|
| 何も選ばずに4つ目に乗せる | 4 | なし |
| マウスを外に出す | 0 | なし |
| 3つ目をクリックする | 3 | 3つ目 |
| 5つ目に乗せる | 5 | 3つ目 |
| マウスを外に出す | 3 | 3つ目 |
| 3つ目をもう一度クリックする | 3 | 3つ目 |
表の最後の行のとおり、選んだ星をもう一度クリックしても、選択は外れません。radio ボタンは、クリックしても選ぶものが切り替わるだけで、選択を外すことはできません。「評価しない」に戻せるようにしたいときは、別に取り消しのボタンを用意する必要があります(この記事では作っていません)。
キーボードでは、矢印キーで星の数が変わる
radio ボタンをそのまま使っているので、キーボードの操作はブラウザが受け持ちます。検証環境で、何も選んでいない状態から、表の上から順にキーを押した結果です。
| 押したキー | フォーカスのある場所 | 選ばれている星 |
|---|---|---|
| Tab | 1つ目の星 | なし |
| → | 2つ目の星 | 2つ目 |
| → | 3つ目の星 | 3つ目 |
| Shift+Tab のあと Tab | 3つ目の星 | 3つ目 |
| ← | 2つ目の星 | 2つ目 |
| ← を2回(1つ目からさらに左) | 5つ目の星 | 5つ目 |
| →(5つ目からさらに右) | 1つ目の星 | 1つ目 |
| Space | 1つ目の星 | 1つ目 |
| Tab | 送信ボタン | 1つ目 |
何も選んでいないときに Tab キーで入ると、フォーカスは1つ目の星に移りますが、まだ選ばれてはいません。ここで右矢印キーを押すと、2つ目に移って選ばれます。1つ目を選びたいときは、Space キーを押します。ページを読み込み直し、何も選んでいない状態から Tab キー、Space キーの順に押すと、1つ目が選ばれました。
なお、すでに選ばれている星で Space キーを押しても、何も変わりませんでした(上の表の Space の行)。一度選んだあとは、Tab キーで戻ってくると、選んだ星にフォーカスが移ります。星5つで Tab キーの止まる場所は1か所だけなので、星を1つずつ Tab キーで移る必要はありません。
1つ目でさらに左矢印キーを押すと5つ目に、5つ目でさらに右矢印キーを押すと1つ目に移りました。評価の入力としては少し意外ですが、W3C の ARIA Authoring Practices Guide にある radio グループの例でも、端の radio ボタンから反対の端へ移る動きになっています。
フォーカスの枠は、.star:has(:focus-visible) で label に付けています。radio ボタン自体は見えないので、中の radio ボタンにフォーカスがあるときに、外側の label に枠を出す書き方です。キーボードで操作したときは枠が出て(outline が solid)、マウスでクリックしたときは出ませんでした(none)。MDN の :focus-visible の説明にあるとおり、ブラウザが「フォーカスを見せたほうがよい」と判断したときだけ当てはまるためです。
フォームで送られる値と、必須にしたときの動き
フォームの中に置くと、選んだ星の radio ボタンの value が、name の名前で送られます。検証では、送信のときに FormData の中身を書き出す画面を用意しました。
import { useState, type FormEvent } from 'react';
import { StarRating } from './StarRating';
function App() {
const [current, setCurrent] = useState(0);
const [sent, setSent] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
setSent(JSON.stringify(Object.fromEntries(data)));
};
return (
<form onSubmit={handleSubmit}>
<StarRating name="rating" label="この記事の評価" required onChange={setCurrent} />
<p id="current">選んだ値:{current}</p>
<button type="submit">送信</button>
<p id="sent">{sent}</p>
</form>
);
}
3つ目を選んで送信すると、{"rating":"3"} が送られました。値は数値ではなく文字列の "3" なので、数値として使うときは変換します。
required は、星5つの radio ボタンすべてに付けています。MDN の radio ボタンの解説によると、同じ name のグループのどれかに required があれば、グループのどれか1つが選ばれている必要があります。何も選ばずに送信ボタンを押すと、checkValidity() は false になり、送信の処理は呼ばれませんでした(書き出した値は空のまま)。
form.checkValidity(): false
validationMessage: Please select one of these options.
送信された値: ""
validationMessage は、検証環境の Chromium では英語でした。このとき、フォーカスは1つ目の星の radio ボタンに移っていました。MDN の radio ボタンの解説の、入力の検証の節によると、どれも選ばれていなければ、ブラウザが選ぶように求めます。選ばれているかどうかを確かめる処理は自分で書かずに、ブラウザに任せられます。





