Reactで、2つのつまみで範囲を選ぶスライダーを作る方法
2つのつまみで範囲を選ぶスライダーは、<input type="range"> を2つ同じ場所に重ねると、ライブラリを使わずに作れます。CSS で input の箱はクリックを受けないようにし、つまみだけが受けるようにします。React 側では下限と上限の2つの数を state で持ち、onChange で、下限が上限を越えないように値を抑えます。
ただし、これだけでは、2つのつまみが同じ値で重なったときに、つまみを左へ動かせなくなります。その直し方も含めて、価格の範囲を選ぶ RangeSlider を作り、マウスとキーボードでの動きを確かめました。
検証環境は 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 を操作して、2つの input の値や、画面に出る範囲の表示を調べました。マウスの操作は Playwright の page.mouse で行い、タッチ操作は試していません。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

2つの input を重ねて、下限と上限を state で持つ
コンポーネントの全体は次のとおりです。下限と上限は [下限, 上限] の配列として親から受け取り、変えるときは onChange で親に新しい配列を渡します。
import { useId, useRef } from 'react';
type Props = {
label: string;
min: number;
max: number;
step?: number;
value: [number, number];
onChange: (value: [number, number]) => void;
names?: [string, string];
format?: (n: number) => string;
};
export function RangeSlider({ label, min, max, step = 1, value, onChange, names, format = String }: Props) {
const [low, high] = value;
const labelId = useId();
// 上限のつまみを押した時点で2つが重なっていたら true(最初に値が動くまで)
const startedOverlapped = useRef(false);
// 重なった状態から最初に左へ動いたら true。ドラッグが終わるまで、上限ではなく下限を動かす
const moveLowInstead = useRef(false);
// 値を、つまみの動く範囲の中の割合(%)にする
const percent = (v: number) => ((v - min) / (max - min)) * 100;
function handleLowChange(v: number) {
// 下限は上限を越えない
onChange([Math.min(v, high), high]);
}
function handleHighChange(v: number) {
if (startedOverlapped.current) {
// 最初に動いた向きで、このドラッグで動かすつまみを決める
moveLowInstead.current = v < low;
startedOverlapped.current = false;
}
if (moveLowInstead.current) {
handleLowChange(v);
return;
}
// 上限は下限を下回らない
onChange([low, Math.max(v, low)]);
}
function endPress() {
startedOverlapped.current = false;
moveLowInstead.current = false;
}
return (
<div className="range" role="group" aria-labelledby={labelId}>
<div className="range-header">
<span id={labelId}>{label}</span>
<span className="range-value">
{format(low)} 〜 {format(high)}
</span>
</div>
<div
className="range-track"
style={{ '--low': `${percent(low)}%`, '--high': `${percent(high)}%` } as React.CSSProperties}
>
<input
type="range"
min={min}
max={max}
step={step}
value={low}
name={names?.[0]}
aria-label={`${label}の下限`}
aria-valuetext={format(low)}
onChange={(e) => handleLowChange(Number(e.target.value))}
/>
<input
type="range"
min={min}
max={max}
step={step}
value={high}
name={names?.[1]}
aria-label={`${label}の上限`}
aria-valuetext={format(high)}
onPointerDown={() => {
startedOverlapped.current = low === high;
moveLowInstead.current = false;
}}
onPointerUp={endPress}
onPointerCancel={endPress}
onChange={(e) => handleHighChange(Number(e.target.value))}
/>
</div>
</div>
);
}
2つの input には value を渡しているので、どちらも controlled な input です。React の公式リファレンスにある、state で input を制御する説明のとおり、React は input の値を、渡した value に合わせ続けます。この性質を使って、onChange で抑えた値を input に戻しています。MDN の input type="range" の説明にあるとおり、range の値は数を表す文字列なので、Number() で数にしてから比べます。
startedOverlapped と moveLowInstead の2つの ref は、重なったつまみの扱いに使います。理由は後の節で説明します。
使う側では、価格の範囲を state で持ち、範囲に入る商品の数を数えて表示しました。
import { useState } from 'react';
import { RangeSlider } from './RangeSlider';
const PRICES = [1980, 4500, 8800, 12000, 15800, 19800, 24000, 29800, 36000, 42000, 48000];
const yen = (n: number) => `${n.toLocaleString('ja-JP')}円`;
export function App() {
const [price, setPrice] = useState<[number, number]>([10000, 30000]);
const [sent, setSent] = useState('');
const count = PRICES.filter((p) => price[0] <= p && p <= price[1]).length;
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
setSent(JSON.stringify(Object.fromEntries(data)));
}
return (
<form onSubmit={handleSubmit}>
<RangeSlider
label="価格"
min={0}
max={50000}
step={1000}
value={price}
onChange={setPrice}
names={['min_price', 'max_price']}
format={yen}
/>
<p className="count">{count}件の商品</p>
<button type="submit">この条件で探す</button>
<output className="sent">{sent}</output>
</form>
);
}
検証環境で、つまみをマウスでドラッグした結果は次のとおりです。値は step の 1,000 ごとに動き、両端の 0 と 50,000 にも届きました。
下限のつまみを 10000 → 20000 の位置へドラッグ: 下限 20000 上限 30000 / 表示「20,000円 〜 30,000円」 / 2件の商品
上限のつまみを 30000 → 45000 の位置へドラッグ: 下限 20000 上限 45000 / 表示「20,000円 〜 45,000円」 / 4件の商品
下限のつまみを 20000 → 0 の位置(左端)へドラッグ: 下限 0 上限 45000 / 表示「0円 〜 45,000円」 / 10件の商品
上限のつまみを 45000 → 50000 の位置(右端)へドラッグ: 下限 0 上限 50000 / 表示「0円 〜 50,000円」 / 11件の商品
つまみだけがクリックを受けるようにする
見た目とクリックの受け方は CSS で決めます。主な指定は次のとおりです。
.range-track {
position: relative;
height: 28px;
}
/* 線は、つまみの半径(10px)だけ内側から描く */
.range-track::before {
content: '';
position: absolute;
left: 10px;
right: 10px;
top: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(
to right,
#d5dde1 var(--low),
#00c8e8 var(--low),
#00c8e8 var(--high),
#d5dde1 var(--high)
);
}
.range-track input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
appearance: none;
background: none;
/* input の箱はクリックを受けず、つまみだけが受ける */
pointer-events: none;
}
.range-track input::-webkit-slider-thumb {
appearance: none;
pointer-events: auto;
width: 20px;
height: 20px;
border: 2px solid #00c8e8;
border-radius: 50%;
background: #fff;
cursor: grab;
}
2つの input は、position: absolute と inset: 0 で同じ場所に重なります。後に書いた上限の input が上に描かれるので、そのままでは、下限のつまみの上にも上限の input の箱がかぶさります。
そこで、input には pointer-events: none を付け、つまみには pointer-events: auto を付けます。MDN の pointer-events の説明にあるとおり、none の要素はポインターのイベントの対象にならず、イベントはその下にある要素に届きます。つまみの見た目は、検証に使った Chromium では ::-webkit-slider-thumb、Firefox では ::-moz-range-thumb で指定します。MDN では、どちらも標準ではない擬似要素とされています。検証に使った CSS には、Firefox 用にも同じ見た目と pointer-events: auto の指定を書きましたが、Firefox では試していません。
input の pointer-events: none がある場合とない場合で、どちらも下限 10,000、上限 30,000 から同じ操作をして比べました。
pointer-events: none あり(この記事の書き方)
下限のつまみの位置にある要素: 価格の下限
下限のつまみを 10000 → 20000 の位置へドラッグ: 下限 20000 上限 30000 / 表示「20,000円 〜 30,000円」 / 2件の商品
線の上(45000 の位置)をクリック: 下限 20000 上限 30000 / 表示「20,000円 〜 30,000円」 / 2件の商品
pointer-events: none なし
下限のつまみの位置にある要素: 価格の上限
下限のつまみを 10000 → 20000 の位置へドラッグ: 下限 10000 上限 20000 / 表示「10,000円 〜 20,000円」 / 3件の商品
線の上(45000 の位置)をクリック: 下限 10000 上限 45000 / 表示「10,000円 〜 45,000円」 / 7件の商品
pointer-events: none がないと、下限のつまみの位置にあるのは上限の input でした。下限のつまみをドラッグしたつもりでも、上限が 30,000 から 20,000 に動き、下限は動きません。線の上をクリックすると、上限がその位置に飛びました。この記事の書き方では、下限のつまみの位置にあるのは下限の input で、ドラッグすると下限が動きます。つまみのない線の上をクリックしても、値は変わりません。線の上をクリックしてつまみを動かすことはできないので、そうしたいときは別に処理が必要です。
色の付いた線を、つまみの中心に合わせる
2つのつまみの間の水色の線は、::before に linear-gradient で描いています。どこから水色にするかは、コンポーネントで計算した割合を CSS 変数の --low と --high で渡しています。
線を input の端から端まで描くと、色の切り替わりがつまみの中心から数 px ずれます。検証環境の Chromium では、range のつまみは、値が最小のときも最大のときも input の箱の中に収まるように描かれます。そのため、この例でつまみの中心が動くのは、箱の両端から 10px(つまみの半径)内側に入った範囲です。線も、つまみの半径だけ内側から描くと、色の切り替わりがつまみの中心と合います。
つまみの幅が 20px、input の幅が 360px のスライダーで、スクリーンショットの画素を読んで確かめました。値が 0 と 50,000 のとき、つまみの中心は、どちらも箱の端から 10px 内側にありました。下限が 10,000 のとき、端から端まで描いた線では、水色がつまみの中心より 5〜6px 左から始まりました。半径だけ内側から描いた線では、水色はつまみの中心から始まりました。
下限が上限を越えないようにする
handleLowChange では Math.min(v, high) で、下限が上限を越えないようにしています。下限のつまみを、上限より右の 40,000 の位置までドラッグして確かめました。
40000 の位置まで動かした(ボタンは押したまま): 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
ボタンを離した: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
ドラッグしている途中でも、下限の input の値は 30,000 のままで、つまみは上限のつまみのところで止まりました。抑えた値を onChange で親に渡すと、React が input の値をその値に戻すためです。
値を抑えない書き方(onChange([v, high]) のように受け取った値をそのまま渡す書き方)では、次のようになりました。
下限を 10000 → 40000 の位置へドラッグ: 下限 40000 上限 30000 / 表示「40,000円 〜 30,000円」
下限が上限より大きくなり、範囲の表示が「40,000円 〜 30,000円」と逆になりました。2つの値の小さいほうを下限として扱う、という書き方もできます。ただ、その場合は「価格の下限」という名前の input が、上限の値を持つことになります。
WAI-ARIA の Authoring Practices Guide(APG)にある、つまみが複数あるスライダーの説明でも、範囲の下限と上限を決めるスライダーのように、つまみが互いを追い越せないものが多い、と書かれています。例に挙がっているのは、価格の範囲を選ぶスライダーです。下限のつまみが動ける最大の値は、上限の値で決まります。
重なったつまみを、左へも動かせるようにする
下限が上限を越えないようにすると、2つのつまみが同じ値で重なることがあります。重なっているときに上に描かれるのは、上限のつまみです。値を抑えるだけの書き方では、このとき、重なったつまみを左へ動かせなくなります。重なったつまみをドラッグして、この記事の書き方と比べました。
この記事の書き方
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
重なったつまみを左(20000)へドラッグ: 下限 20000 上限 30000 / 表示「20,000円 〜 30,000円」 / 2件の商品
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
重なったつまみを右(40000)へドラッグ: 下限 30000 上限 40000 / 表示「30,000円 〜 40,000円」 / 1件の商品
キーボードで2つとも右端(50000)にする: 下限 50000 上限 50000 / 表示「50,000円 〜 50,000円」 / 0件の商品
右端で重なったつまみを左(35000)へドラッグ: 下限 35000 上限 50000 / 表示「35,000円 〜 50,000円」 / 3件の商品
越えないようにするだけの書き方
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」
重なったつまみを左(20000)へドラッグ: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」
重なったつまみを右(40000)へドラッグ: 下限 30000 上限 40000 / 表示「30,000円 〜 40,000円」
キーボードで2つとも右端(50000)にする: 下限 50000 上限 50000 / 表示「50,000円 〜 50,000円」
右端で重なったつまみを左(35000)へドラッグ: 下限 50000 上限 50000 / 表示「50,000円 〜 50,000円」
越えないようにするだけの書き方では、重なったつまみを左へドラッグしても、どちらも動きませんでした。押しているのは上に描かれた上限のつまみで、上限は下限より小さくできないからです。右へのドラッグなら上限が動きますが、2つとも右端の 50,000 で重なったときは、右にも左にも動かせなくなりました。上限のつまみの下に隠れた下限のつまみは、マウスではつかめません。
この記事の書き方では、上限のつまみを押した時点で2つが重なっていたかを、onPointerDown で startedOverlapped に記録します。重なっていたら、最初に値が動いた向きで、そのドラッグで動かすつまみを決めます。左へ動いたら moveLowInstead を true にします。そのドラッグが終わるまでは、上限の input に届いた値で下限を動かします。上限の input の値は、React が high のまま保つので、上限のつまみはその場に残り、カーソルの下では下限のつまみが動きます。冒頭の GIF の4回目が、この動きです。最初に右へ動いたときは、これまでどおり上限が動きます。
向きは、ボタンを押しているあいだ変えません。押した時点で重なっていなければ、上限のつまみを下限より左まで引いても、上限が下限の位置で止まるだけです。重なったつまみを左へ引いてから、ボタンを離さずに右へ戻したときは、下限が上限と同じ 30,000 で止まり、上限はついてきませんでした。逆に、右へ引いてから左へ戻したときは、上限が下限と同じ 30,000 で止まり、下限は動きませんでした。もう一方のつまみを動かすには、いったんボタンを離して押し直します。
上限のつまみを 30000 → 5000 の位置へドラッグ: 下限 10000 上限 10000 / 表示「10,000円 〜 10,000円」 / 0件の商品
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
重なったつまみを 20000 の位置まで左へ(ボタンは押したまま): 下限 20000 上限 30000 / 表示「20,000円 〜 30,000円」 / 2件の商品
そのまま 40000 の位置まで右へ戻す: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
下限を 30000 まで動かして重ねる: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
重なったつまみを 40000 の位置まで右へ(ボタンは押したまま): 下限 30000 上限 40000 / 表示「30,000円 〜 40,000円」 / 1件の商品
そのまま 20000 の位置まで左へ戻す: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
startedOverlapped と moveLowInstead を state ではなく ref にしたのは、画面の表示に使わない値だからです。useRef の公式リファレンスのとおり、ref の値を変えても再レンダーは起きません。
startedOverlapped を onPointerDown でだけ true にしているのは、キーボードの操作を対象から外すためです。重なっているときに、上限の input にフォーカスして左矢印キー(ArrowLeft)を押すと、上限は動きません。下限に移ってから押すと、下限が動きます。
重ねてから、上限にフォーカスして ArrowLeft: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
Shift+Tab で下限に移って ArrowLeft: 下限 29000 上限 30000 / 表示「29,000円 〜 30,000円」 / 1件の商品
ドラッグの終わりは、onPointerUp と onPointerCancel で受け取り、2つの ref を戻します。重なったつまみを左へ動かしたあと、カーソルを input の外まで下げてからボタンを離す操作も試しました。そのあと、フォーカスが残った上限の input で ArrowLeft を押すと、下限ではなく上限が 30,000 から 29,000 に動きました。input の外でボタンを離しても、ref は戻っています。
キーボードでの操作と、読み上げ用の名前
2つとも普通の <input type="range"> なので、キーボードでの操作はブラウザが受け持ちます。Tab キーで下限、上限の順にフォーカスが移ります。APG の説明には、つまみの値や見た目の位置が変わっても、Tab キーで移る順番は変えない、と書かれています。DOM の順番が下限、上限で変わらないので、この書き方では順番は変わりません。検証環境のキー操作の結果は次のとおりです。
Tab: 価格の下限
ArrowRight: 下限 11000 上限 30000 / 表示「11,000円 〜 30,000円」 / 5件の商品
ArrowUp: 下限 12000 上限 30000 / 表示「12,000円 〜 30,000円」 / 5件の商品
ArrowLeft: 下限 11000 上限 30000 / 表示「11,000円 〜 30,000円」 / 5件の商品
PageUp: 下限 16000 上限 30000 / 表示「16,000円 〜 30,000円」 / 3件の商品
End: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
Home: 下限 0 上限 30000 / 表示「0円 〜 30,000円」 / 8件の商品
Tab: 価格の上限
Home: 下限 0 上限 0 / 表示「0円 〜 0円」 / 0件の商品
End: 下限 0 上限 50000 / 表示「0円 〜 50,000円」 / 11件の商品
矢印キーでは step の 1,000 ずつ、Chromium 153 の PageUp では 5,000 動きました。下限で End を押すと、最大の 50,000 ではなく、上限と同じ 30,000 で止まりました。onChange で値を抑えているので、キーボードでもマウスでも同じように止まります。
input には aria-label で「価格の下限」「価格の上限」という名前を付け、全体を role="group" で囲んで、ラベルの「価格」を aria-labelledby で結びつけています。Playwright で読んだアクセシビリティツリーは次のとおりです。
- group "価格":
- text: 価格 10,000円 〜 30,000円
- slider "価格の下限": "10000"
- slider "価格の上限": "30000"
aria-valuetext には「10,000円」のような、単位の付いた文字列を渡しています。MDN の aria-valuetext の説明によると、aria-valuetext があるときは、数の値の代わりにこの文字列が読み上げられます。
この文字列が実際に読み上げられるかは、確かめられませんでした。スクリーンリーダーでは試しておらず、Chrome DevTools Protocol の Accessibility.getFullAXTree で読んだ Chromium の内部のアクセシビリティツリーでも、slider の valuetext の項目に aria-valuetext の文字列が出てこなかったためです。比べるために置いた role="slider" の div でも出てこなかったので、この項目の値からは、読み上げられるかどうかを判断できませんでした。
もう1つ、APG の、つまみが複数あるスライダーの説明では、相手の値によって動ける範囲が変わるスライダーは、値が変わるたびに aria-valuemin や aria-valuemax を更新する、とされています。この記事のコンポーネントは、2つの input の min と max を 0 と 50,000 のままにしていて、これに沿っていません。下限を 20,000 にした状態で getFullAXTree を読むと、下限の valuemax は、実際に止まる上限の 30,000 ではなく 50,000 でした。APG に沿わせるなら、下限の aria-valuemax と上限の aria-valuemin を、相手の値に合わせて更新することになります。そうした書き方はこの記事では試していないので、作るときはアクセシビリティツリーに出る値を確かめてください。
フォームで送ると、2つの値がそのまま入る
2つの input には name を付けているので、フォームの値として送られます。下限を 15,000 にして「この条件で探す」を押すと、FormData の中身は次のようになりました。
送信された値: {"min_price":"15000","max_price":"30000"}
値はどちらも文字列なので、受け取る側では数に直してから比べる必要があります。





