Reactで、ドラッグで数値を増減できる入力欄を作る方法
ラベルを左右にドラッグすると数値が増減する入力欄は、ラベルに onPointerDown と onPointerMove を付け、押した位置からの距離を「何段階ぶんか」に直して値に足せば作れます。押した瞬間に setPointerCapture を呼んでおくと、ポインターがラベルの外に出ても値が変わり続けます。
数値を打つ欄は <input type="number"> のまま残すので、キーボードでの数字の入力や ↑ キーでの増減も、これまでどおり使えます。幅と不透明度を変える2つの欄で、ドラッグ、Shift を押したときの10倍の動き、小数の丸め、クリックとドラッグの区別を確かめました。
検証環境は 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 の page.mouse でマウスを操作しました。タッチ操作、ペン、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

ラベルをつまみにして、押した位置からの距離で値を決める
コンポーネントの全体は次のとおりです。値は親が state で持ち、value と onChange で受け渡します。pixelsPerStep は、何 px 動かすと値が step 1つ分変わるかを決める値です。
import { useId, useRef, useState } from 'react';
import { roundToStep } from './roundToStep';
type Props = {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
pixelsPerStep?: number; // 何px動かすと step 1つ分変わるか
};
type Drag = {
pointerId: number;
startX: number;
startValue: number;
lastX: number;
factor: number; // 1、Shift で 10
moved: boolean;
};
export function DragNumberInput({
label, value, onChange, min = -Infinity, max = Infinity, step = 1, pixelsPerStep = 2,
}: Props) {
const id = useId();
const drag = useRef<Drag | null>(null);
const [draft, setDraft] = useState<string | null>(null); // 打っている途中の文字列
function onPointerDown(e: React.PointerEvent<HTMLLabelElement>) {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { pointerId: e.pointerId, startX: e.clientX, startValue: value, lastX: e.clientX, factor: 1, moved: false };
}
function onPointerMove(e: React.PointerEvent<HTMLLabelElement>) {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
const factor = e.shiftKey ? 10 : 1;
if (factor !== d.factor) {
// Shift を押した・離したときは、1つ前の位置と今の値から数え直す
d.startX = d.lastX;
d.startValue = value;
d.factor = factor;
}
d.lastX = e.clientX;
const dx = e.clientX - d.startX;
if (Math.abs(dx) >= 3) d.moved = true;
if (!d.moved) return;
const steps = Math.trunc(dx / pixelsPerStep);
const next = roundToStep(d.startValue + steps * step * factor, min, max, step);
if (next !== value) onChange(next);
}
function endDrag() {
// moved は、この後の click で使うので残しておく
if (drag.current) drag.current.pointerId = -1;
}
function onClick(e: React.MouseEvent<HTMLLabelElement>) {
// ドラッグの後の click では、入力欄にフォーカスを移さない
if (drag.current?.moved) e.preventDefault();
drag.current = null;
}
function commit(text: string) {
const n = Number(text);
if (text.trim() !== '' && Number.isFinite(n)) onChange(roundToStep(n, min, max, step));
setDraft(null);
}
return (
<div className="drag-number">
<label
htmlFor={id}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClick={onClick}
>
{label}
</label>
<input
id={id}
type="number"
value={draft ?? String(value)}
min={Number.isFinite(min) ? min : undefined}
max={Number.isFinite(max) ? max : undefined}
step={step}
onChange={(e) => {
const text = e.target.value;
setDraft(text);
const n = Number(text);
// 範囲の中の数なら、打っている途中でもすぐに反映する
if (text !== '' && n >= min && n <= max) onChange(n);
}}
onBlur={(e) => commit(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit(e.currentTarget.value);
}}
/>
</div>
);
}
値は、動くたびに少しずつ足していくのではなく、「押したときの値 + 押した位置からの距離ぶんの段階」で毎回計算し直します。こうしておくと、値は押した位置からの距離だけで決まり、途中で pointermove が何回届いたかには左右されません。距離を段階に直すときは Math.trunc で端数を切り捨てるので、2px ごとに1段階ずつ変わります。
検証には、次の画面を使いました。
<DragNumberInput label="幅" value={width} onChange={setWidth} min={20} max={300} step={1} />
<DragNumberInput label="不透明度" value={opacity} onChange={setOpacity} min={0} max={1} step={0.01} />
<div className="preview" style={{ width, opacity }} />
<p className="state">幅 {width} / 不透明度 {opacity}</p>
幅の初期値は 120 です。「幅」のラベルを押して左右に動かした結果は、次のとおりでした。同じ画面で、続けて3回ドラッグしています。
| ドラッグ | 幅 |
|---|---|
| 右へ 40px | 140 |
| 左へ 100px | 90 |
| 右へ 400px | 290 |
2回目以降は、前のドラッグの結果から始まります。120 → 140 → 90 → 290 と、ドラッグした距離の半分ずつ変わっています。
ラベルの見た目は CSS で決めます。
.drag-number label {
width: 7em;
cursor: ew-resize;
user-select: none;
touch-action: none;
border-bottom: 1px dashed #067a98;
color: #067a98;
}
cursor: ew-resize は、ラベルの上でのカーソルを左右の矢印の形にして、ドラッグできることを知らせるための指定です。user-select: none は、ドラッグ中にラベルの文字が選択されないようにするためです。touch-action: none は、MDN によるとブラウザーがパンやズームのジェスチャーを扱わなくなる指定で、指でラベルを動かしたときにページがスクロールしないように付けています。
setPointerCapture で、ラベルの外まで追いかける
ラベルの幅は 112px しかないので、ドラッグするとすぐにポインターがラベルの外に出ます。MDN の setPointerCapture() のページにあるとおり、ポインターキャプチャを設定すると、キャプチャが解放されるまで、そのポインターのイベントはキャプチャした要素に届きます。解放は pointerup か pointercancel のときに自動で行われるので、自分で releasePointerCapture を呼ぶ必要はありません。
setPointerCapture の1行を消したものと比べました。どちらも、ラベルの中で右へ 30px 動かし、下へ 120px 外れてから、さらに右へ 200px 動かしています。
| 操作 | setPointerCapture あり |
なし |
|---|---|---|
| ラベルの中で右へ 30px | 135 | 135 |
| ラベルの外で、さらに右へ 200px | 235 | 135 |
| ラベルの外でボタンを離す | 235 | 135 |
| ボタンを押さずにラベルの上に戻り、右へ 20px | 235 | 130 |
setPointerCapture がないと、ラベルの外での動きが届かず、値は 135 で止まりました。さらに、ラベルの外で離した pointerup も届かないので、ドラッグが終わったことになりません。ボタンを押していないのにラベルの上でポインターを動かすと、値がまた変わりました。
Shift を押したら、その位置から10倍で数え直す
Shift を押している間は、1段階を step の10倍にします。押しているかどうかは、pointermove のイベントの shiftKey で見ています。
ここで、押した位置からの距離をそのまま10倍すると、Shift を押した瞬間に、それまで動かした距離のぶんまで10倍になって値が跳びます。そこで、Shift を押した・離したときは、1つ前の位置と今の値を新しい起点にして、そこから数え直しています。
ドラッグの途中で Shift を押し、また離したときの「幅」の値です。
| 操作 | 幅 |
|---|---|
| Shift なしで右へ 20px | 130 |
| Shift を押して、右へ 1px | 130 |
| Shift を押したまま、さらに右へ 20px | 230 |
| Shift を離して、さらに右へ 20px | 240 |
Shift を押した直後は値が変わらず、その後の 20px で 100(10段階 × 10)増えました。離した後の 20px は、また10だけ増えています。
小数の step は、桁数をそろえて丸める
不透明度のように step が 0.01 の欄では、0.8 + 段階 × 0.01 をそのまま値にすると、浮動小数点の誤差が出ます。Node.js で計算した結果です。
0.8 + -7 × 0.01 → そのまま 0.73 / roundToStep 0.73
0.8 + -37 × 0.01 → そのまま 0.43000000000000005 / roundToStep 0.43
0.8 + 3 × 0.01 → そのまま 0.8300000000000001 / roundToStep 0.83
0.43000000000000005 が入力欄に出ると読みにくいので、step の小数の桁数で丸める関数を通します。
// step の小数の桁数(0.01 なら 2)
function decimals(step: number): number {
const s = String(step);
return s.includes('.') ? s.split('.')[1].length : 0;
}
// v を、min を基準にした step の倍数に丸め、min〜max の中に収める
export function roundToStep(v: number, min: number, max: number, step: number): number {
const base = Number.isFinite(min) ? min : 0;
const n = Math.round((v - base) / step) * step + base;
const fixed = Number(n.toFixed(decimals(step)));
return Math.min(max, Math.max(min, fixed));
}
toFixed() は、指定した桁数の文字列を返すので、Number で数に戻しています。
段階の基準を min にしているのは、MDN の input type="number" の step の説明にあるとおり、ブラウザーも min を基準に正しい値を決めるからです。この決まりに従うと、step が 5 で min が 3 なら、正しい値は 3、8、13…になります。roundToStep(57.3, 3, 300, 5) は 58 になり、min がないとき(-Infinity)は 0 を基準にして 55 になりました。
検証環境の Chromium で「不透明度」のラベルをドラッグしたときも、入力欄には丸めた値が出ました。初期値 0.5 から左へ 14px で 0.43、続けて左へ 60px で 0.13、右へ 200px で 1 です。
クリックだけなら入力欄へ、ドラッグの後は移さない
MDN の label のページにあるとおり、ラベルをクリックすると、関連付けた入力欄にフォーカスが移ります。数字を打ちたい人がラベルをクリックしたときは、この動きのままにしておきたいところです。
一方、ドラッグを終えてボタンを離したときにも、ラベルには click が届きます。検証環境では、何もしなければドラッグの後でも入力欄にフォーカスが移りました。
そこで、3px 以上動いたときを「ドラッグした」とみなして moved を true にし、その後の click で preventDefault() を呼んで、入力欄へのフォーカスを止めています。
| 操作 | 幅 | フォーカスのある要素 |
|---|---|---|
| ラベルをクリック | 120(変わらない) | 入力欄 |
| 2px だけ動かして離す | 120(変わらない) | 入力欄 |
| 10px 動かして離す | 125 | body(入力欄に移らない) |
10px 動かして離す(preventDefault の行を消したもの) |
125 | 入力欄 |
2px のような小さなぶれは、クリックとして扱われます。3px という境目は、この記事で決めた値です。
打っている途中の文字列は、別の state で持つ
入力欄に数字を打つときは、value をそのまま数として持つと困ることがあります。たとえば最小値が 20 の「幅」に「50」と打とうとすると、「5」を打った時点で範囲の外になります。そこで、打っている途中の文字列を draft という別の state に持ち、範囲の中の数になったときだけ親に渡しています。範囲の外の数は、フォーカスが外れたときか Enter を押したときに、roundToStep で範囲の中に収めます。
検証環境で「幅」の入力欄を操作した結果です。
| 操作 | 入力欄 | 幅 |
|---|---|---|
| ↑ を2回押す | 122 | 122 |
| 空にして「5」と打った途中 | 5 | 122(変わらない) |
| 続けて「0」と打つ | 50 | 50 |
| 「999」と打って Enter | 300 | 300 |
| 「7」と打って Tab | 20 | 20 |
| 空にして Tab | 20 | 20(元に戻る) |
入力欄は type="number" なので、↑ を押すと step ずつ値が増えます。Playwright の getByRole('spinbutton', { name: '幅' }) で入力欄が見つかったことから、ラベルの文字がそのまま入力欄の名前になっていることも確かめました。MDN の input type="number" のアクセシビリティの節によると、この入力欄の暗黙のロールは spinbutton です。ドラッグはマウスで操作する人のための近道で、キーボードだけでも同じ値を入れられます。
draft が null のときは String(value) を渡し、入力欄の value が null にならないようにしています。React の input のリファレンスには、値を管理する入力欄に渡す value を undefined や null にしないように書かれています。





