Reactで、ドラッグで回して値を変えるダイヤルを作る方法
つまみの中心から見たポインターの向きを Math.atan2 で求め、前回の向きとの差をつまみの角度に足していくと、ドラッグで回すダイヤルを React だけで作れます。ポインターの向きをそのままつまみの角度にする書き方もありますが、それだと真下を越えた瞬間に値が最大から 0 へ飛びました。
作るのは、音量を 0〜100 で選ぶ Knob コンポーネントです。つまみは真上を 0 度として左右に 135 度ずつ、合わせて 270 度だけ回ります。マウスと指のドラッグに加え、キーボードでも操作できるようにします。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスとキーボードは Playwright で操作し、タッチは Chromium の DevTools プロトコル(CDP)の Input.dispatchTouchEvent で、スマートフォンの表示を模した画面に送りました。実機のスマートフォン、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。マウスホイールでの操作も、この記事では扱いません。

Knob コンポーネントの全体
先にコード全体を載せます。見た目は SVG で描き、ドラッグは Pointer Events で受け取ります。
import { useRef } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';
type Props = {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
};
// つまみが回る範囲(真上を 0 度とし、時計回りを正にする)
const MIN_ANGLE = -135;
const MAX_ANGLE = 135;
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n));
}
// 要素の中心から見た、ポインターの向き(-180〜180 度)
function pointerAngle(el: HTMLElement, x: number, y: number) {
const r = el.getBoundingClientRect();
const dx = x - (r.left + r.width / 2);
const dy = y - (r.top + r.height / 2);
return (Math.atan2(dx, -dy) * 180) / Math.PI;
}
type Drag = {
pointerId: number;
lastAngle: number; // 前回の pointermove のときのポインターの向き
rotation: number; // つまみの角度(端数を持ったまま足していく)
};
export function Knob({ label, value, min = 0, max = 100, step = 1, onChange }: Props) {
const drag = useRef<Drag | null>(null);
const toAngle = (v: number) => MIN_ANGLE + ((v - min) / (max - min)) * (MAX_ANGLE - MIN_ANGLE);
const toValue = (angle: number) => {
const raw = ((angle - MIN_ANGLE) / (MAX_ANGLE - MIN_ANGLE)) * (max - min);
return clamp(min + Math.round(raw / step) * step, min, max);
};
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
// つまみの外へ出ても、離すまでこの要素にイベントが届くようにする
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = {
pointerId: e.pointerId,
lastAngle: pointerAngle(e.currentTarget, e.clientX, e.clientY),
rotation: toAngle(value),
};
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
const angle = pointerAngle(e.currentTarget, e.clientX, e.clientY);
let delta = angle - d.lastAngle;
// 真下(180 度と -180 度の境目)をまたいだときに、逆向きの大きな差にしない
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
d.lastAngle = angle;
d.rotation = clamp(d.rotation + delta, MIN_ANGLE, MAX_ANGLE);
const next = toValue(d.rotation);
if (next !== value) onChange(next);
}
function handlePointerEnd(e: PointerEvent<HTMLDivElement>) {
if (drag.current?.pointerId === e.pointerId) drag.current = null;
}
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const keys: Record<string, number> = {
ArrowRight: value + step,
ArrowUp: value + step,
ArrowLeft: value - step,
ArrowDown: value - step,
PageUp: value + step * 10,
PageDown: value - step * 10,
Home: min,
End: max,
};
if (!(e.key in keys)) return;
e.preventDefault(); // 矢印キーなどでページがスクロールしないようにする
onChange(clamp(keys[e.key], min, max));
}
const angle = toAngle(value);
return (
<div
className="knob"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onKeyDown={handleKeyDown}
>
<svg viewBox="0 0 160 160" aria-hidden="true">
{/* 目盛りの円弧。pathLength を 360 にして、角度をそのまま長さに使う */}
<circle className="knob-track" cx="80" cy="80" r="72" pathLength={360}
strokeDasharray="270 360" transform="rotate(135 80 80)" />
<circle className="knob-fill" cx="80" cy="80" r="72" pathLength={360}
strokeDasharray={`${angle - MIN_ANGLE} 360`} transform="rotate(135 80 80)" />
<g transform={`rotate(${angle} 80 80)`}>
<circle className="knob-body" cx="80" cy="80" r="56" />
<line className="knob-mark" x1="80" y1="34" x2="80" y2="52" />
</g>
</svg>
</div>
);
}
使う側では、値を state で持って渡します。
import { useState } from 'react';
import { Knob } from './Knob';
export function App() {
const [volume, setVolume] = useState(50);
return (
<main>
<Knob label="音量" value={volume} onChange={setVolume} />
<p className="value">音量 <output>{volume}</output></p>
</main>
);
}
ポインターの向きを Math.atan2 で求める
pointerAngle は、つまみの中心からポインターまでの横の差 dx と縦の差 dy から、向きを度で返します。Math.atan2(y, x) は、x 軸の正の向き(画面では右)を 0 として、-π〜π の範囲の角度をラジアンで返す関数です。コードでは * 180 / Math.PI で度に直しています。
ここでは引数を Math.atan2(dx, -dy) と入れ替えています。こうすると、真上が 0 度、右が 90 度、左が -90 度になり、時計回りに数が増えます。真下は 180 度と -180 度の境目です。画面の y 座標は下へ行くほど大きいので、dy の符号を逆にしています。
つまみの角度と値は、toAngle と toValue で行き来させます。-135 度が min、135 度が max です。toValue では step の倍数に丸めるので、step を 5 にすれば 5 刻みで動きます(検証で試したのは step が 1 の場合だけです)。
前回との差を足していくと、真下を越えても値が飛ばない
handlePointerMove では、ポインターの向きをそのまま使わず、前回の pointermove からの差 delta をつまみの角度 rotation に足します。足したあとは -135〜135 度の範囲に収めます。
比べるために、次の2つの版も作りました。
- ポインターの向きを -135〜135 度に収めて、そのままつまみの角度にする版
- 差を足すが、真下をまたいだときの補正(
deltaを ±360 する2行)がない版
3つの版それぞれで、真上(0 度)でつかみ、つまみの中心から 60px のところを 5 度ずつ時計回りに 225 度(左下)までなぞりました。そのあと、135 度の位置まで戻しています。
--- 完成版(差分を足す) ---
真上でつかむ: 50
時計回り: 45度→67 90度→83 135度→100 170度→100 180度→100 185度→100 190度→100 225度→100
戻す: 180度→83 135度→67
値 50 のまま、右でつかんで 1px 動かす: 50
--- 向きをそのまま使う版 ---
真上でつかむ: 50
時計回り: 45度→67 90度→83 135度→100 170度→100 180度→100 185度→0 190度→0 225度→0
戻す: 180度→100 135度→100
値 50 のまま、右でつかんで 1px 動かす: 84
--- 真下の補正なしの版 ---
真上でつかむ: 50
時計回り: 45度→67 90度→83 135度→100 170度→100 180度→100 185度→0 190度→2 225度→15
戻す: 180度→100 135度→83
値 50 のまま、右でつかんで 1px 動かす: 50
「時計回り」の行の 185度 のところを見てください。向きをそのまま使う版では、ポインターが真下を越えて 185 度の位置に来たとたん、値が 100 から 0 に飛びました。Math.atan2 の結果が 180 度から -175 度へ切り替わり、それを -135 度に収めたためです。
補正のない版では、180 度から 185 度へ動いた1回の差が -355 度になります。そのため、つまみの角度が左端まで振り切れて 0 になり、その先の動きで 2、15 と増えました。delta が 180 度より大きいときは 360 を引き、-180 度より小さいときは 360 を足すと、この大きな差が、本来の向きの小さな差に直ります。
完成版では、135 度の位置で 100 になったあと、真下を越えても 100 のまま止まりました。逆に回すと、すぐに値が下がり始めます(135 度で 67)。これは、範囲の外へ回した分を rotation に残さず、毎回 -135〜135 度に収めているからです。
各版の最後の行は、値が 50 のつまみを右(90 度)でつかみ、1px だけ動かした結果です。向きをそのまま使う版では、つかんだ位置の向きに合わせて 84 に飛びました。差を足す2つの版では、つかんだだけでは値は変わりません。
この補正には、裏返しの面もあります。pointermove が1回届くあいだにポインターが 180 度を超えて回ると、逆向きの動きとして扱われます。完成版で、値 50 のつまみの真上をつかみ、途中を通らずに1回の pointermove で動かしてみました。
--- 完成版:1回の pointermove で大きく動かす ---
値 50 で真上をつかみ、1回で時計回りに 170 度の位置へ: 100
値 50 で真上をつかみ、1回で時計回りに 190 度の位置へ: 0
時計回りに 170 度の位置へ動かすと最大の 100 になりましたが、190 度の位置へ動かすと、反時計回りに 170 度回したとみなされて 0 になりました。
差を足す書き方では、1回の pointermove から次の pointermove までに回る角度が 180 度より小さいことを前提にしています。つまみの中心のすぐ近くをなぞると、少し動かしただけで向きが大きく変わるので、この前提が崩れやすくなります。
つまみの外へ出ても回し続けられるようにする
handlePointerDown では、setPointerCapture を呼んでいます。これで、ボタンや指を離すまで、ポインターがつまみの外へ出てもつまみに pointermove が届きます。
つまみは 160×160px です。右(90 度)でつかんだまま中心から 150px まで外へ離れ、その距離のまま真下(180 度)まで時計回りになぞったところ、値は次のように変わりました。なぞった点は、すべて表示領域の中です。
--- 完成版:つまみの外まで離れて回す ---
右のまま半径 150px まで外へ出たあとの値: 50
半径 150px で時計回り: 135度→67 180度→83
180 度・半径 150px の点: (180, 246)/表示領域 360×320
つまみの大きさ: 160×160px
45 度回すごとに 50、67、83 と変わりました。半径 60px でなぞったとき(真上から 45 度で 67、90 度で 83)と同じ増え方です。値を決めるのは向きだけなので、中心から離れてなぞるほど、同じ角度を回すのにマウスを大きく動かすことになります。
指でなぞるときは touch-action: none を付ける
CSS では、つまみに touch-action: none を付けています。
.knob {
display: inline-block;
width: 160px;
height: 160px;
border-radius: 50%;
cursor: grab;
/* 指でなぞっても、ページがスクロールしないようにする */
touch-action: none;
user-select: none;
}
つまみの上を指でなぞったときに、ブラウザにスクロールやズームをさせず、すべての動きを pointermove としてページに渡すための指定です。ダイヤルは縦にも横にもなぞるので、スワイプのように縦だけブラウザに任せる、といった分け方はできません。
つまみの touch-action だけを auto に変えて、指で真上から時計回りに 90 度なぞり、2つを比べました。
touch-action: none: 真上から時計回りに 90 度なぞる: 値 83 / scrollY 0 / イベント: pointerdown pointerup
touch-action: auto: 真上から時計回りに 90 度なぞる: 値 56 / scrollY 0 / イベント: pointerdown pointercancel
auto のときは、なぞり始めてすぐに pointercancel が届き、値は 56 で止まりました。ブラウザが指の動きを、スクロールやズームの操作として扱い始めたためです。どちらも scrollY が 0 なのは、ページがいちばん上にあり、指を下へ動かしてもそれより上へはスクロールできないからです。比べて分かるのは、pointercancel が届いたかどうかと、値の違いです。none のときは、最後に pointerup が届き、値はマウスのときと同じ 83 になりました。
キーボードでも操作できるようにする
ドラッグだけでは、マウスや指を使えない人が値を変えられません。そこで、つまみの要素に role="slider" と tabIndex={0} を付けてフォーカスできるようにし、onKeyDown でキーごとに値を変えています。
キーの割り当ては、WAI-ARIA Authoring Practices Guide のスライダーのパターンに合わせました。右と上の矢印で1つ増やし、左と下の矢印で1つ減らします。Home と End では最小と最大へ移ります。Page Up と Page Down は、APG では1段より大きく動かす任意の操作とされています。ここでは 10 ずつ動かすことにしました。今の値は aria-valuenow に、範囲は aria-valuemin と aria-valuemax に入れます。
キーを順に押して、aria-valuenow の値を確かめました。
--- 完成版:キーボード ---
Home→0 ArrowUp→1 ArrowRight→2 ArrowDown→1 PageUp→11 PageUp→21 End→100 ArrowUp→100 PageDown→90 ArrowLeft→89
表示: 音量 89
属性: role=slider aria-label=音量 aria-valuemin=0 aria-valuemax=100
End で 100 になったあとに上の矢印を押しても、100 のまま増えませんでした。受け付けたキーでは e.preventDefault() を呼び、ページのスクロールなど、ブラウザの既定の動きが起きないようにしています。
目盛りの円弧は pathLength で角度のまま描く
つまみのまわりの円弧は、同じ大きさの <circle> を2つ重ねています。下は灰色で 270 度分、上は水色で今の値の分です。
円の線の長さを角度のまま扱えるように、pathLength を 360 にしています。こうすると、stroke-dasharray の "270 360" は「270 度分を描き、残りは描かない」という意味になります。SVG の仕様では、円の線は 3 時の位置から時計回りに描かれます。そこで、rotate(135 80 80) で回して、左下(-135 度)から描き始めるようにしています。
つまみの本体と目印の線は <g> にまとめ、rotate(${angle} 80 80) で中心を軸に回します。SVG 全体には aria-hidden="true" を付け、絵の部分をアクセシビリティツリーから外しています。値は、つまみの要素の aria-valuenow で伝えます。





