Reactで、ルーレットを回して項目を1つ選ぶ方法
ルーレットで項目を1つ選ぶときは、先に Math.random() で当たりの項目を決め、その項目が矢印の下に来る角度を計算して、盤を CSS の transition で回します。止まった角度から当たりを読み取るのではなく、当たりから角度を決めるので、表示と結果がずれません。結果は、回転の transition が終わったときの transitionend で出します。
盤は conic-gradient() で塗り分けた円で、transform: rotate() の角度を state で持ちます。回転角は毎回 0 度から計算せず、前の角度に足し続けます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作しました。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

当たりを先に決めて、そこで止まる角度を計算する
角度の計算は、React から切り離した関数にしました。index 番目の項目が12時の矢印の下で止まる回転角を返します。
// 当たりの項目(index)が、12時の矢印の下で止まる回転角を返す
// current: 今の回転角(度)。rand: 0以上1未満の乱数。turns: 余分に回す周回数
export function targetRotation(current: number, index: number, count: number, rand: number, turns = 5): number {
const seg = 360 / count;
// 項目の中のどこで止めるか(境目を避けて、幅の10%〜90%の間)
const stopAt = index * seg + seg * (0.1 + 0.8 * rand);
// 盤を r 度回すと、盤の stopAt 度の点は stopAt + r 度の位置に来る。
// これが 360 の倍数(12時)になる r のうち、今より先にある最初のものを探す
const goal = (360 - stopAt) % 360;
const now = ((current % 360) + 360) % 360;
const delta = (goal - now + 360) % 360;
return current + turns * 360 + delta;
}
// 回転角から、矢印の下にある項目を求める(確かめる用)
export function indexAt(rotation: number, count: number): number {
const a = ((-rotation % 360) + 360) % 360;
return Math.floor(a / (360 / count));
}
角度は、どれも「12時から時計回り」で数えます。盤の塗り分けに使う conic-gradient() は、MDN の conic-gradient() の説明にあるとおり、12時の方向から時計回りに色が並びます。transform の rotate() も、正の角度は時計回りです。そのため、6項目なら index 番目の項目は盤の index × 60 度から60度ぶんの範囲にあり、盤を r 度回すと、盤の上の角度 a の点は a + r 度の位置に来ます。
止める位置は、項目の幅の10%〜90%の間で乱数で決めます。いつも項目の真ん中で止まると作り物らしく見え、境目ぎりぎりで止まると、どちらの項目か見分けにくいからです。この幅の決め方は、この記事で決めたものです。
計算が合っているかは、Node.js で確かめました。6項目で当たりを Math.random() で決め、targetRotation で角度を出し、その角度から indexAt で矢印の下の項目を求める、という流れを、前の角度に足しながら60,000回続けました。
60000回: 矢印の下の項目と当たりが違った回数 0
1回で回った角度: 最小 1800.0度 / 最大 2160.0度
項目ごとの当たりの回数: 10062, 10038, 9929, 10087, 9991, 9893
当たりと矢印の下の項目は、すべて一致しました。1回に回る角度は、5周(1,800度)に、目標の位置までの1周未満の角度を足したものなので、1,800度以上2,160度未満です。乱数を 0 と 0.999999 にした両端でも、矢印の下は盤の6度と54度(項目0)、306度と354度(項目5)のように、項目の端から6度内側に収まりました。
当たりの決め方は Math.floor(Math.random() * 項目数) です。MDN の Math.random() の説明には、この乱数は暗号に使えるほど安全ではなく、セキュリティに関わる目的には Crypto.getRandomValues() を使うように書かれています。昼ごはんを決めるような用途なら Math.random() で足ります。
ルーレットのコンポーネント
コンポーネントの全体は次のとおりです。項目の文字列の配列を items で受け取ります。
import { useRef, useState } from 'react';
import { targetRotation } from './spin';
const COLORS = ['#00c8e8', '#ffd166', '#90caf9', '#8fd694', '#b39ddb', '#ff9f68'];
type Props = { items: string[] };
export function Roulette({ items }: Props) {
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [result, setResult] = useState<string | null>(null);
const winner = useRef<number | null>(null);
const seg = 360 / items.length;
function spin() {
if (spinning || items.length === 0) return;
// 先に当たりを決めてから、そこで止まる角度を計算する
const index = Math.floor(Math.random() * items.length);
// 動きを減らす設定なら、余分な周回をしない(1周未満だけ回す)
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const next = targetRotation(rotation, index, items.length, Math.random(), reduce ? 0 : 5);
winner.current = index;
if (next === rotation) {
// 角度が変わらないと transition が起きず、transitionend も来ないので、すぐ結果を出す
setResult(items[index]);
return;
}
setResult(null);
setSpinning(true);
setRotation(next);
}
function onTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
// 子要素から伝わってきたイベントや、transform 以外の transition は無視する
if (e.target !== e.currentTarget || e.propertyName !== 'transform') return;
setSpinning(false);
if (winner.current !== null) setResult(items[winner.current]);
}
const gradient = `conic-gradient(${items
.map((_, i) => `${COLORS[i % COLORS.length]} ${i * seg}deg ${(i + 1) * seg}deg`)
.join(', ')})`;
return (
<div className="roulette">
<div className="stage">
<div className="pointer" aria-hidden="true" />
<div
className="wheel"
style={{ background: gradient, transform: `rotate(${rotation}deg)` }}
onTransitionEnd={onTransitionEnd}
>
{items.map((item, i) => (
<span
key={i}
className="label"
style={{ transform: `translate(-50%, -50%) rotate(${(i + 0.5) * seg}deg) translateY(-90px)` }}
>
{item}
</span>
))}
</div>
</div>
<button type="button" onClick={spin} disabled={spinning}>
{spinning ? '回っています…' : '回す'}
</button>
<p className="result" role="status">{result ? `結果:${result}` : ''}</p>
</div>
);
}
conic-gradient() には、#00c8e8 0deg 60deg, #ffd166 60deg 120deg, … のように、色ごとに始まりと終わりの角度を2つ並べて渡しています。MDN の conic-gradient() の説明にあるとおり、隣り合う色の境目の角度を同じにすると、色が混ざらずに、くっきり塗り分けられます。
項目名の <span> は盤の中心に置き、項目の真ん中の角度だけ回してから、回した向きのまま外側へ 90px ずらしています。盤の子要素なので、盤といっしょに回ります。検証環境で回す前の位置を測ると、「カレー」の中心は盤の中心から 90px、12時から時計回りに30度の位置でした。
検証には、6項目(カレー、ラーメン、寿司、パスタ、定食、サラダ)を渡した画面を使いました。盤と矢印の CSS は次のとおりです(ボタンや結果の文字の見た目の指定は省いています)。
.stage { position: relative; width: 280px; height: 280px; }
.wheel {
position: absolute; inset: 0; border-radius: 50%;
border: 4px solid #1f2a30; box-sizing: border-box;
transition: transform 3s cubic-bezier(0.15, 0.85, 0.25, 1);
}
.label { position: absolute; left: 50%; top: 50%; font-weight: bold; white-space: nowrap; }
.pointer {
position: absolute; left: 50%; top: -6px; z-index: 1; translate: -50% 0;
border-left: 12px solid transparent; border-right: 12px solid transparent;
border-top: 22px solid #1f2a30;
}
@media (prefers-reduced-motion: reduce) {
.wheel { transition-duration: 0.3s; }
}
cubic-bezier(0.15, 0.85, 0.25, 1) は、始めに速く回って、終わりに向けてゆっくり止まる動きにするための指定です。検証環境で、1回目(1,933度)について、回り始めてからの時間ごとに全体の何%まで回ったかを測ると、0.3秒で47%、0.75秒で79%、1.5秒で95%でした(全体は3秒)。矢印は、上の辺だけ色を付けた枠線で描いた三角です。
検証環境の Chromium で5回続けて回し、止まった後に3つのことを比べました。表示された結果、getComputedStyle で読んだ盤の見た目の角度から求めた矢印の下の項目、矢印のすぐ下の画素の色です。
| 回 | 回転角 | 結果の表示 | 角度から求めた項目 | 矢印の下の色 |
|---|---|---|---|---|
| 1 | 0 → 1932.99 | パスタ | パスタ | パスタの緑 |
| 2 | → 3824.88 | 寿司 | 寿司 | 寿司の青 |
| 3 | → 5724.51 | カレー | カレー | カレーの水色 |
| 4 | → 7842.57 | ラーメン | ラーメン | ラーメンの黄 |
| 5 | → 9977.76 | ラーメン | ラーメン | ラーメンの黄 |
5回とも3つが一致しました。回っている間、ボタンは「回っています…」になって押せず、止まると「回す」に戻りました。検証では、毎回同じ結果になるように、Math.random をシードを固定した乱数に置き換えています。
回転角は、0 度に戻さずに足し続ける
targetRotation の最初の引数に今の回転角を渡し、そこに足していくのには理由があります。毎回 0 度から計算すると、2回目以降の回り方がおかしくなるからです。
targetRotation(rotation, …) の rotation を 0 に変えたものと比べました。どちらも、同じ順に同じ項目が当たるようにしています。回っている間のアニメーションを止めて 10ms ずつ進め、見た目の角度の変化を足し合わせて、実際に回った角度を求めました。
| 回 | 足し続ける(記事のコード) | 毎回 0 度から計算 |
|---|---|---|
| 1 | +1933.0度 | +1933.0度 |
| 2 | +1891.9度 | +91.9度 |
| 3 | +1899.6度 | +99.6度 |
| 4 | +2118.1度 | −41.9度(反時計回り) |
毎回 0 度から計算すると、2回目の目標の角度は 2024.88 度で、1回目に止まった 1932.99 度との差は 92 度しかありません。CSS の transition は、今の値から新しい値までの間をつなぐだけなので、盤は3秒かけて4分の1周ほどしか回りませんでした。4回目は目標の角度が今より小さくなり、盤が反時計回りに戻りました。どちらも止まる項目は正しいのですが、ルーレットには見えません。
足し続けると、回転角は回すたびに大きくなります。60,000回足し続けたときの値は、約1億1,878万度でした。Node.js の計算では、この大きさになっても、当たりと矢印の下の項目は60,000回すべて一致しました。ブラウザで試したのは、前の節の表の 9977.76 度までです。
結果は transitionend で出し、回っている間はボタンを止める
結果を出すのは、盤が止まった後です。止まったことは、transitionend イベントで分かります。MDN の transitionend イベントの説明にあるとおり、このイベントは CSS の transition が終わったときに発生し、propertyName に transition した CSS プロパティの名前が入ります。
onTransitionEnd の最初の行では、イベントの target が盤そのものか、propertyName が transform かを確かめています。検証環境で、盤の子要素に opacity の transition を付けて動かすと、その transitionend も盤に届きました(target は子要素、propertyName は opacity)。
この確認の行を消したものでも試しました。項目名に 0.2 秒の色の transition を足し、回り始めて 0.5 秒後に「カレー」の文字色を変えると、盤がまだ回っている約 0.8 秒の時点で「結果:パスタ」が出て、ボタンも押せる状態に戻りました。記事のコードでは、同じ操作をしても、結果が出たのは盤が止まったとき(回り始めて約 3.3 秒後)でした。
回っている間は、spinning を true にしてボタンを disabled にしています。検証環境でボタンをすばやく2回クリックしても、回転角は1回目のクリックで決まった 1932.99 度のままで、transitionend も1回だけでした。spin の最初の if (spinning …) return; も、同じように二重に回るのを防ぐためのものです。
結果を表示する <p> には role="status" を付けました。MDN の status ロールの説明によると、このロールは暗黙に aria-live="polite" を持つライブリージョンで、中身が変わったことを、ユーザーの操作を中断せずに支援技術へ知らせるためのものです。
動きを減らす設定では、周回をやめて短く回す
MDN の prefers-reduced-motion の説明によると、大きく動くアニメーションは、前庭運動障碍のある人に不快感を引き起こすことがあります。OS で動きを減らす設定をしている人の端末では、prefers-reduced-motion が reduce になります。
この記事のコードでは、2か所でこの設定に合わせています。CSS の @media (prefers-reduced-motion: reduce) で transition の長さを 0.3 秒にし、spin の中で matchMedia を使って同じ条件を調べ、targetRotation の turns を 0 にしています。turns が 5 のままだと、1回に1,800度以上回ります。時間だけを 0.3 秒に短くすると、その角度を0.3秒で回ることになり、かえって速く動きます。
Playwright で動きを減らす設定(reducedMotion: 'reduce')にして、3回回しました。
| 回 | 回転角 | 回った角度 | 結果 |
|---|---|---|---|
| 1 | 0 → 132.986 | +133.0度(0.3秒) | パスタ |
| 2 | → 224.882 | +91.9度(0.3秒) | 寿司 |
| 3 | → 324.513 | +99.6度(0.3秒) | カレー |
どれも1周未満で止まり、止まった項目と結果は一致しました。
動きを減らす設定のときに、CSS で transition: none にしたものも検証環境で試しました。5秒待っても結果が出ず、transitionend は0回で、ボタンも「回っています…」のまま押せませんでした。盤は止まる位置に移っていて、角度から求めた矢印の下の項目はパスタでした。前の節で触れた MDN の transitionend イベントの説明にも、待ち時間と実行時間がどちらも 0 秒なら transition は起きず、イベントも発生しない、と書かれています。transition を外して、transitionend を待たずに処理する書き方は、このブログの「Reactで、スワイプで消せるリストを作る方法」で試しています。
turns を 0 にすると、別の落とし穴もあります。ふつうの Math.random() ではまず起きませんが、乱数を固定したテストなどで、同じ項目の同じ位置が2回続けて当たると、目標の角度が今の角度と同じになり、transition が起きません。Math.random がいつも 0.5 を返すようにして2回回すと、2回目の回転角は 150 度のまま変わりませんでした。spin の if (next === rotation) は、このときに transitionend を待たず、すぐに結果を出すためのものです。この if を消したものでは、2回目は3秒待っても結果が出ず、ボタンが押せないままになりました。記事のコードでは、2回目もボタンは「回す」のまま押せて、表示は「結果:パスタ」のままでした(盤は動かず、表示も変わりません)。





