# Range sliders that only move by dragging

Source: https://easeweb.dev/learn/sliders
Topics: Custom inputs > Sliders and range sliders work with arrow keys and without dragging (WCAG 2.1.1, 4.1.2, 2.5.7)
The fix: Either use two native range inputs, one for each end, or build each thumb as a role="slider" with its values, the arrow keys and a click on the track, or keep the drag slider for the mouse and add a number field for each end. Whichever you choose, name each end, and read the value with its unit.
Test with: keyboard, screen reader, mouse, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/range

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's product list has a price filter: a track from $0 to $200 with two round thumbs, and "$20 – $160" written above it. With a mouse, you press on a thumb, slide it, and the products update when you let go. Press Tab and focus jumps from the category list straight to the Apply button: neither thumb can take focus. A screen reader reads "Price, $20 – $160" as text, if it reaches it at all, and nothing it can change. On a phone, a thumb is small enough that a press often lands beside it, and the page scrolls instead.

## Why it fails

The thumbs are `span` elements moved by pointer events, so the filter fails three criteria at once. Nothing can be done from the keyboard, so it fails 2.1.1. The thumbs have no role, name or value, so a screen reader can't say what they are or where they are set, which fails 4.1.2. And the only way to set a price is to drag, which fails 2.5.7: picking a price is not a task that needs a drag.

The same failure comes in quieter forms. A thumb may have `role="slider"` and `tabindex="0"` but no key handler, so it takes focus and then ignores the arrows. It may move with the arrows while its `aria-valuenow` stays at the value it loaded with. It may read "20" with no unit, or two thumbs may both be called "Price". Keyboard support doesn't settle 2.5.7 either: the criterion asks for a way that works with a single pointer, and a person on a touch screen has no arrow keys.

## Who is affected

A price or date filter is often the only way to narrow a long list, so a slider no one else can move leaves people scrolling through every product. On a phone, the thumbs are usually smaller than the 24 pixels most other controls get, which makes them hard to catch for anyone.

## What automation and AI miss

A scanner sees two empty spans and a line of text, and finds nothing wrong: spans need no name, and nothing marks them as a control. axe reports a `role="slider"` with no name or no `aria-valuenow`, but not one whose arrows do nothing or whose value never changes; only pressing the keys shows that. Asked to fix the slider, code assistants often add `role="slider"` and `tabindex="0"` and stop there, which puts a silent, stuck control in the Tab order. They rarely add a way to set the value without dragging, because nothing in the markup asks for one.

## Before: two thumbs moved only by dragging

```html
<div class="price">
  <span class="price-label">Price</span>
  <span class="price-values">$20 – $160</span>
  <div class="track">
    <span class="thumb" data-thumb="min" style="left: 10%"></span>
    <span class="thumb" data-thumb="max" style="left: 80%"></span>
  </div>
</div>
```

```js
thumb.addEventListener('pointerdown', startDrag);
// pointermove sets style.left and the text; pointerup applies the filter
```

The thumbs can't take focus, so the keyboard can't reach them. They have no role, name or value, so assistive technology doesn't know they are controls. And pointer events are the only way in, so every price needs a drag.

## After: two native range inputs

```html
<fieldset class="price">
  <legend>Price</legend>
  <label for="price-min">Minimum</label>
  <input type="range" id="price-min" min="0" max="200" step="10" value="20" aria-valuetext="$20">
  <output for="price-min">$20</output>
  <label for="price-max">Maximum</label>
  <input type="range" id="price-max" min="0" max="200" step="10" value="160" aria-valuetext="$160">
  <output for="price-max">$160</output>
</fieldset>
```

```js
const low = document.getElementById('price-min');
const high = document.getElementById('price-max');
for (const input of [low, high]) {
  input.addEventListener('input', () => {
    // Each end stops at the other.
    if (low.valueAsNumber > high.valueAsNumber) input.value = (input === low ? high : low).value;
    const price = `$${input.value}`;
    input.setAttribute('aria-valuetext', price);
    input.nextElementSibling.textContent = price;
  });
}
```

The browser gives each input focus, the arrow keys, Page Up, Page Down, Home and End, and a role and value a screen reader reads. A click or tap on the track moves the thumb there, and because that is the browser's own behaviour, 2.5.7 leaves it to the browser. `aria-valuetext` makes the value read as "$20" rather than "20", and the legend and labels name each end "Price, Minimum". The cost is the look: two tracks, one under the other, rather than one with two thumbs. Don't lay the two inputs over each other on one track with `pointer-events: none`: that turns off the click on the track, and the drag becomes the only pointer action again.

## After: a custom two-thumb slider

```html
<div class="price" role="group" aria-labelledby="price-label">
  <span id="price-label">Price</span>
  <span class="price-values">$20 – $160</span>
  <div class="track">
    <span class="thumb" role="slider" tabindex="0" aria-label="Minimum price"
      aria-valuemin="0" aria-valuemax="160" aria-valuenow="20" aria-valuetext="$20"></span>
    <span class="thumb" role="slider" tabindex="0" aria-label="Maximum price"
      aria-valuemin="20" aria-valuemax="200" aria-valuenow="160" aria-valuetext="$160"></span>
  </div>
</div>
```

```js
const [low, high] = track.querySelectorAll('[role="slider"]');
const num = (thumb, name) => Number(thumb.getAttribute(`aria-value${name}`));
// Sets a thumb, kept on a $10 step and between its limits; the drag calls this too.
function set(thumb, value) {
  value = Math.min(num(thumb, 'max'), Math.max(num(thumb, 'min'), Math.round(value / 10) * 10));
  thumb.setAttribute('aria-valuenow', value);
  thumb.setAttribute('aria-valuetext', `$${value}`);
  thumb.style.left = `${value / 2}%`;
  // Each thumb stops at the other.
  (thumb === low ? high : low).setAttribute(thumb === low ? 'aria-valuemin' : 'aria-valuemax', value);
}
const KEYS = { ArrowRight: 10, ArrowUp: 10, ArrowLeft: -10, ArrowDown: -10, PageUp: 50, PageDown: -50 };
for (const thumb of [low, high]) {
  thumb.addEventListener('keydown', (event) => {
    const now = num(thumb, 'now');
    if (event.key in KEYS) set(thumb, now + KEYS[event.key]);
    else if (event.key === 'Home') set(thumb, num(thumb, 'min'));
    else if (event.key === 'End') set(thumb, num(thumb, 'max'));
    else return;
    event.preventDefault();
  });
}
// A click on the track moves the nearer thumb there: no drag needed.
track.addEventListener('click', (event) => {
  if (event.target !== track) return;
  const box = track.getBoundingClientRect();
  const value = ((event.clientX - box.left) / box.width) * 200;
  const near = Math.abs(value - num(low, 'now')) <= Math.abs(value - num(high, 'now')) ? low : high;
  set(near, value);
  near.focus();
});
```

Each thumb is a slider in the Tab order with its own name, and its value is read with the unit. The arrow keys move it a step, Page Up and Page Down five, Home and End to its limit, and each thumb's limit follows the other, as the multi-thumb slider pattern asks. A click or tap on the track sets the nearer thumb without a drag. This keeps the one-track look, but the script owns everything the browser would have done, and it must be tested with each screen reader you support. A tap reaches every value only while each step has room on the track: 21 steps of $10 fit on any phone, but a range of $0 to $1,000 in $1 steps does not, and then add the number fields from the next fix.

## After: number fields beside the slider

```html
<fieldset class="price">
  <legend>Price</legend>
  <div class="track" aria-hidden="true">
    <span class="thumb" data-thumb="min" style="left: 10%"></span>
    <span class="thumb" data-thumb="max" style="left: 80%"></span>
  </div>
  <label for="price-min">Minimum ($)</label>
  <input type="number" id="price-min" min="0" max="200" step="10" value="20">
  <label for="price-max">Maximum ($)</label>
  <input type="number" id="price-max" min="0" max="200" step="10" value="160">
</fieldset>
```

```js
const fields = { min: document.getElementById('price-min'), max: document.getElementById('price-max') };
// The fields move the thumbs…
for (const [end, field] of Object.entries(fields)) {
  field.addEventListener('input', () => {
    track.querySelector(`[data-thumb="${end}"]`).style.left = `${field.value / 2}%`;
  });
}
// …and the existing drag writes into the fields: fields[thumb.dataset.thumb].value = value;
```

The drag slider stays as it was for people who like it, and two labelled number fields do the same job by typing, by tapping, and with the arrow keys. They are native, so they have a role, a name and a value, and the drag writes into them, so they always show where the thumbs are. `aria-hidden` takes the drag-only track out of the accessibility tree, so a screen reader meets the fields and not two empty controls. It is the smallest change to an existing slider, and the only one of the three that sets an exact price in one step. Check the minimum against the maximum when the filter is applied, and say so beside the fields if it is higher.

## Which option to use

All three pass. The native range inputs take the least code and get keys, values and the click on the track from the browser; choose them when two tracks are acceptable in the design. The custom slider keeps one track with two thumbs, but you write and test the keys, the values and the track click yourself; choose it when the design needs one track and the team can test it with screen readers. The number fields leave an existing slider untouched and add exact typing for everyone; choose them when the slider comes from a library you can't change, or when people need precise values, such as a mortgage or a date range.

## Implementation decisions

The value a screen reader hears is `aria-valuetext` when it is set, and `aria-valuenow` when it is not. Use it whenever the number alone isn't the value: "$20", "3 nights", "Wednesday". Keep it short, and keep the name out of it: the name already says "Minimum price".

A few things decide how the slider behaves once it is in a page:

- Applying the filter: if the products reload on every change, an arrow key press reloads the page, and a screen reader hears the list change ten times. Wait until the key is released for a moment, or use an Apply button, and say the result count in a status region.
- Vertical sliders: set `aria-orientation="vertical"` on a custom slider, and make Up raise the value. Native range inputs can be drawn vertically with `writing-mode: vertical-lr`.
- Touch size: the thumbs and the track should take a tap of at least 24 by 24 pixels; make the hit area larger than the drawn thumb with padding or a pseudo-element.
- Forced colors: draw the thumbs and the filled part of the track with borders or `currentColor`, so they don't vanish in a contrast theme.

Slider libraries vary. Some ship role, keys and values but no track click; some set `aria-valuenow` but no `aria-valuetext`. Check what the element that takes focus exposes, not what the library's documentation promises.

## Verify the fix

1. Put the mouse aside. Press Tab and confirm you reach each end of the range, or its field, in order.
2. On each end, press the arrow keys, Page Up, Page Down, Home and End, and confirm the value moves by a step, by a larger step, and to each limit, and that one end can't pass the other.
3. With a screen reader, confirm each end announces its name, its role and its value with the unit, and announces the new value after each key press.
4. With a mouse, using clicks only, set each end to a new value: a click on the track, a click into a field, or the browser's own range input. No step may need the pointer to move while pressed.
5. On a touch screen, set both ends with taps only. With voice control, set each end by saying its visible label.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the slider component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. Screen readers differ in how they read range inputs and custom sliders: some read the value before the name, and some on iOS move a slider by swiping up and down rather than with keys. Sliders that pick a colour, set a time on a media player, or resize a panel have needs of their own and are not covered here. If your "slider" is a row of slides with arrows, see [carousels that keyboard and screen reader users can't use](/learn/carousels). Test the complete filter journey with your target assistive technology.
