Reactで、ドラッグで回して値を変えるダイヤルを作る方法

​​React​Playwright,Pointer Events,SVG,TypeScript,アクセシビリティ

つまみの中心から見たポインターの向きを 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、スクリーンリーダーでの読み上げは試していません。マウスホイールでの操作も、この記事では扱いません。

1回目は真上から右へ回して音量が 50 から 83 へ上がり、2回目は右でつかんで回すと 100 で止まり、真下を越えて左下まで回しても 100 のまま、3回目は左でつかんで反時計回りに半周すると 33 まで下がる。値が変わったコマだけ数字がピンクになる
マウスでダイヤルを回す3回の操作(丸はマウスの位置で、押している間は濃くなる。丸と上の表示は GIF 用に重ねたもの)

Knob コンポーネントの全体

先にコード全体を載せます。見た目は SVG で描き、ドラッグは Pointer Events で受け取ります。

tsx
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 で持って渡します。

tsx
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 度の位置まで戻しています。

text
--- 完成版(差分を足す) ---
真上でつかむ: 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 で動かしてみました。

text
--- 完成版: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 度)まで時計回りになぞったところ、値は次のように変わりました。なぞった点は、すべて表示領域の中です。

text
--- 完成版:つまみの外まで離れて回す ---
  右のまま半径 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 を付けています。

css
.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つを比べました。

text
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 の値を確かめました。

text
--- 完成版:キーボード ---
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 で伝えます。

​Posted by 86