フォーム・入力
スライダー(複数つまみ)
解説ありSlider (Multi-Thumb)
価格帯などの範囲選択。つまみ同士の干渉とラベリングが論点。
マルチサム・スライダーとは?
マルチサム(複数つまみ)スライダーは、つまみが2つあり、範囲(下限〜上限)を選ぶ UI です。 「価格帯」「年齢層」「時間帯」などの絞り込みでよく使われます。
難しさは、2つのつまみそれぞれに別々の名前・値・範囲を持たせること、 そして下限が上限を超えないように制約することです。
なぜアクセシビリティが大事なの?
- スクリーンリーダーを使う人。2つのつまみが同じ(または無名の)スライダーだと、 「いまどちらを操作しているのか」「これは下限か上限か」が全く分かりません。 だからつまみごとに別の名前(「最小価格」「最大価格」)が必須です。
- キーボードだけで操作する人。各つまみが個別にフォーカスでき、 矢印キーで動かせる必要があります。さらに、下限が上限を追い越すと値が壊れるため、互いを越えない制約が要ります。
ライブデモ(推奨実装)
下は価格帯を選ぶ2つまみスライダーです。つまみは別々にフォーカスでき、矢印キーで動かせます。下限は上限を超えません。
価格帯
試してみよう:Tab で「最小価格」→「最大価格」へ → ← → で500円ずつ、Page Up / Down で2000円ずつ、Home / End で端まで。最小は最大を越えられない。
ポイント
各つまみにフォーカスすると「最小価格, スライダー, 2000円」「最大価格, スライダー, 6000円」のように、どちらのつまみかと現在値が読み上げられます。aria-label でつまみを区別するのが要点です。
キーボード操作
| キー | 動作 | 必須/任意 |
|---|---|---|
| Tab | つまみ間(最小価格 ↔ 最大価格)を移動 | 必須 |
| → / ↑ | フォーカス中のつまみを1ステップ増やす | 必須 |
| ← / ↓ | フォーカス中のつまみを1ステップ減らす | 必須 |
| Home / End | そのつまみが動ける端まで移動 | 必須 |
| Page Up / Page Down | 大きいステップで増減 | 任意(推奨) |
必要な WAI-ARIA / ロール
| 付ける場所 | 属性 / ロール | 意味 |
|---|---|---|
| 各つまみ | role="slider" | つまみ1つ1つを「スライダー」にする(2つあるなら2つとも)。 |
| 各つまみ | tabindex="0" | つまみを個別にキーボードフォーカス可能にする。 |
| 各つまみ | aria-label(個別に) | 「最小価格」「最大価格」のように区別できる名前を付ける。 |
| 各つまみ | aria-valuenow | そのつまみの現在値。動かすたびに更新。 |
| 下限つまみ | aria-valuemax = 上限の現在値 | 下限が上限を越えないよう、可動範囲を動的に締める。 |
| 上限つまみ | aria-valuemin = 下限の現在値 | 上限が下限を下回らないよう、可動範囲を動的に締める。 |
| 各つまみ | aria-valuetext | 「2000円」など単位付きで読み上げさせる(任意・推奨)。 |
実装:推奨パターン(Good)
良い例 / 推奨
ネイティブの range は1つまみ専用なので、2つまみは role="slider" のつまみを2つ並べて作ります。 各つまみに個別の aria-label と動的な可動範囲を持たせるのがコツです。
マークアップ:
<div class="range" id="price-range">
<span id="price-label">価格帯(円)</span>
<!-- 下限のつまみ -->
<div role="slider"
tabindex="0"
aria-label="最小価格"
aria-valuemin="0"
aria-valuemax="6000" <!-- 上限つまみの現在値まで -->
aria-valuenow="2000"
aria-valuetext="2000円"
data-thumb="min"></div>
<!-- 上限のつまみ -->
<div role="slider"
tabindex="0"
aria-label="最大価格"
aria-valuemin="2000" <!-- 下限つまみの現在値から -->
aria-valuemax="10000"
aria-valuenow="6000"
aria-valuetext="6000円"
data-thumb="max"></div>
</div>キーボード操作と「互いを越えない」制約:
const MIN = 0, MAX = 10000, STEP = 500, BIG = 2000;
const root = document.getElementById('price-range');
const minThumb = root.querySelector('[data-thumb="min"]');
const maxThumb = root.querySelector('[data-thumb="max"]');
function val(t) { return Number(t.getAttribute('aria-valuenow')); }
function render() {
// 互いを越えないよう、各つまみの可動範囲を相手の現在値で締める
minThumb.setAttribute('aria-valuemin', String(MIN));
minThumb.setAttribute('aria-valuemax', String(val(maxThumb)));
maxThumb.setAttribute('aria-valuemin', String(val(minThumb)));
maxThumb.setAttribute('aria-valuemax', String(MAX));
[minThumb, maxThumb].forEach((t) => {
t.setAttribute('aria-valuetext', val(t) + '円');
t.style.setProperty('--pos', ((val(t) - MIN) / (MAX - MIN)) * 100 + '%');
});
}
function onKey(t) {
return (e) => {
const lo = Number(t.getAttribute('aria-valuemin'));
const hi = Number(t.getAttribute('aria-valuemax'));
const now = val(t);
let next = now;
switch (e.key) {
case 'ArrowRight': case 'ArrowUp': next = now + STEP; break;
case 'ArrowLeft': case 'ArrowDown': next = now - STEP; break;
case 'PageUp': next = now + BIG; break;
case 'PageDown': next = now - BIG; break;
case 'Home': next = lo; break;
case 'End': next = hi; break;
default: return;
}
e.preventDefault();
// 相手を越えない範囲に収める(min ≤ max を保証)
t.setAttribute('aria-valuenow', String(Math.min(hi, Math.max(lo, next))));
render();
};
}
minThumb.addEventListener('keydown', onKey(minThumb));
maxThumb.addEventListener('keydown', onKey(maxThumb));
render();アンチパターン(Bad)
下はラベルの無い2つのつまみを、マウスでドラッグするだけのものです。どちらが下限・上限か分からず、キーボードでも操作できません。
価格帯
試してみよう:Tab でつまみにフォーカスできません。スクリーンリーダーでは2つのつまみが見分けられず、値も範囲も伝わりません。
<!-- ❌ アンチパターン:ラベルの無い2つまみ・マウス専用 -->
<div class="track">
<div class="knob" onmousedown="drag(event, this)"></div>
<div class="knob" onmousedown="drag(event, this)"></div>
</div>
<!--
どちらが「最小価格」「最大価格」か区別できない。
role / aria-valuenow が無いので値も範囲も読み上げられない。
tabindex が無いのでキーボードでは触れない。
-->悪い例 / 避ける
この実装の問題点:
- どちらのつまみか分からない —
aria-labelが無く「最小」「最大」を区別できない。 - キーボードで操作できない —
tabindexが無く、矢印キーも効かない。 - 値・範囲が伝わらない —
role="slider"やaria-valuenowが無い。 - 制約が無い — 下限が上限を追い越しても止まらず、値が壊れる。
実装チェックリスト
- つまみ2つにそれぞれ
role="slider"を付けた - つまみごとに区別できる
aria-label(「最小価格」「最大価格」)がある - 各つまみが
tabindex="0"で個別にフォーカスできる - 矢印・Home・End・PageUp/Down で動かせる
- 下限が上限を越えない(可動範囲を相手の値で動的に締めている)
- 動かすたびに
aria-valuenowを更新している - フォーカスが見える(フォーカスリングを消していない)