Reactで、2つのつまみで範囲を選ぶスライダーを作る方法

​​React​CSS,Playwright,TypeScript,アクセシビリティ,フォーム

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、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

価格のスライダーで、下限のつまみを 18,000円までドラッグし、上限のつまみを 44,000円までドラッグする。下限を上限より右へドラッグすると 44,000円で止まり、重なったつまみを左へドラッグすると下限が 26,000円まで動く
4回のドラッグで、範囲の表示と商品の件数が変わる様子。数字が変わったコマだけ、その表示がピンクになる(カーソル、押している間の輪、操作の説明と回数の表示は GIF 用に重ねたもの)

2つの input を重ねて、下限と上限を state で持つ

コンポーネントの全体は次のとおりです。下限と上限は [下限, 上限] の配列として親から受け取り、変えるときは onChange で親に新しい配列を渡します。

tsx
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 で持ち、範囲に入る商品の数を数えて表示しました。

tsx
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 にも届きました。

text
下限のつまみを 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 で決めます。主な指定は次のとおりです。

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 から同じ操作をして比べました。

text
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 の位置までドラッグして確かめました。

text
40000 の位置まで動かした(ボタンは押したまま): 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品
ボタンを離した: 下限 30000 上限 30000 / 表示「30,000円 〜 30,000円」 / 0件の商品

ドラッグしている途中でも、下限の input の値は 30,000 のままで、つまみは上限のつまみのところで止まりました。抑えた値を onChange で親に渡すと、React が input の値をその値に戻すためです。

値を抑えない書き方(onChange([v, high]) のように受け取った値をそのまま渡す書き方)では、次のようになりました。

text
下限を 10000 → 40000 の位置へドラッグ: 下限 40000 上限 30000 / 表示「40,000円 〜 30,000円」

下限が上限より大きくなり、範囲の表示が「40,000円 〜 30,000円」と逆になりました。2つの値の小さいほうを下限として扱う、という書き方もできます。ただ、その場合は「価格の下限」という名前の input が、上限の値を持つことになります。

WAI-ARIA の Authoring Practices Guide(APG)にある、つまみが複数あるスライダーの説明でも、範囲の下限と上限を決めるスライダーのように、つまみが互いを追い越せないものが多い、と書かれています。例に挙がっているのは、価格の範囲を選ぶスライダーです。下限のつまみが動ける最大の値は、上限の値で決まります。

重なったつまみを、左へも動かせるようにする

下限が上限を越えないようにすると、2つのつまみが同じ値で重なることがあります。重なっているときに上に描かれるのは、上限のつまみです。値を抑えるだけの書き方では、このとき、重なったつまみを左へ動かせなくなります。重なったつまみをドラッグして、この記事の書き方と比べました。

text
この記事の書き方
下限を 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 で止まり、下限は動きませんでした。もう一方のつまみを動かすには、いったんボタンを離して押し直します。

text
上限のつまみを 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)を押すと、上限は動きません。下限に移ってから押すと、下限が動きます。

text
重ねてから、上限にフォーカスして 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 の順番が下限、上限で変わらないので、この書き方では順番は変わりません。検証環境のキー操作の結果は次のとおりです。

text
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 で読んだアクセシビリティツリーは次のとおりです。

text
- 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 の中身は次のようになりました。

text
送信された値: {"min_price":"15000","max_price":"30000"}

値はどちらも文字列なので、受け取る側では数に直してから比べる必要があります。

​Posted by 86