Skip to content

Range

A range is a min–max slider: two native <input type="range"> overlaid on one track. Each carries .re-slider, so the thumbs look exactly like a single slider. With zero JS both inputs work, are keyboard-operable, and submit their own name=value. enhanceRange keeps the thumbs from crossing, draws the fill between them, and routes a track click to the nearer thumb.

import { enhanceRange } from "@relements/core/behaviors/range";
enhanceRange(document);
Price range
Live example
<fieldset
  class="re-range"
  data-re-range
  style="--re-range-fill-start: 20%; --re-range-fill-end: 70%"
>
  <legend class="re-field__label">Price range</legend>
  <div class="re-range__track">
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-min
      min="0"
      max="1000"
      step="10"
      value="200"
      aria-label="Minimum price"
      name="price-min"
    />
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-max
      min="0"
      max="1000"
      step="10"
      value="700"
      aria-label="Maximum price"
      name="price-max"
    />
  </div>
  <output class="re-range__output" aria-hidden="true">200 – 700</output>
</fieldset>

data-size="sm|lg" on the .re-range mirrors the slider sizes.

Small
Large
Live example
<fieldset
  class="re-range"
  data-size="sm"
  data-re-range
  style="--re-range-fill-start: 30%; --re-range-fill-end: 60%"
>
  <legend class="re-field__label">Small</legend>
  <div class="re-range__track">
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-min
      min="0"
      max="100"
      value="30"
      aria-label="Small minimum"
    />
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-max
      min="0"
      max="100"
      value="60"
      aria-label="Small maximum"
    />
  </div>
</fieldset>
<fieldset
  class="re-range"
  data-size="lg"
  data-re-range
  style="--re-range-fill-start: 10%; --re-range-fill-end: 50%"
>
  <legend class="re-field__label">Large</legend>
  <div class="re-range__track">
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-min
      min="0"
      max="100"
      value="10"
      aria-label="Large minimum"
    />
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-max
      min="0"
      max="100"
      value="50"
      aria-label="Large maximum"
    />
  </div>
</fieldset>
Unavailable
Live example
<fieldset
  class="re-range"
  data-re-range
  disabled
  style="--re-range-fill-start: 25%; --re-range-fill-end: 75%"
>
  <legend class="re-field__label">Unavailable</legend>
  <div class="re-range__track">
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-min
      min="0"
      max="100"
      value="25"
      aria-label="Disabled minimum"
    />
    <input
      type="range"
      class="re-slider re-range__input"
      data-re-range-max
      min="0"
      max="100"
      value="75"
      aria-label="Disabled maximum"
    />
  </div>
</fieldset>
  • Crossing is prevented by clamping the moved input’s value — both inputs keep their authored min/max (the full range), so each thumb maps to the same track and aria-value* stays honest.
  • data-re-range-gap sets a minimum distance between thumbs (default = step).
  • When the two thumbs fully coincide, separate them with the keyboard (Tab to a thumb, then arrow) — the focused thumb sits on top.
  • Native range thumb mirroring under dir="rtl" is engine-dependent; verify the fill alignment if you ship RTL.

The range is two native <input type="range">, so each thumb is an independent, fully native slider — enhanceRange adds no role or aria-*. It only clamps the moved input’s value (keeping each thumb’s aria-valuemin/max/now honest), draws the fill, and routes track clicks. Everything below works with zero JS.

  • Keyboard — native per thumb: Tab focuses a thumb, arrow keys nudge by step, Page Up/Page Down jump a larger increment, and Home/End go to that input’s min/max. When the two thumbs fully coincide, Tab to one and arrow it off — the focused thumb sits on top (z-index) so it stays grabbable.
  • Focus — each thumb shows a visible :focus-visible ring (--re-shadow-focus on the thumb pseudo-element; the native outline is removed, not dropped). The focused thumb is raised above the other so its ring is never clipped. Disabling the wrapping <fieldset> propagates :disabled to both inputs (and dims the fill), taking them out of the tab order.
  • Semantics — both inputs keep the native slider role and surface their own aria-valuemin/max/now from the authored min/max/value. The <fieldset>/<legend> names the group, and each input also carries an aria-label (e.g. “Minimum price” / “Maximum price”) so the two thumbs are told apart. The .re-range__output readout is aria-hidden="true" — it duplicates the live thumb values, which AT already announces, so hiding it avoids a double report.
  • Notes — the track and fill band are CSS pseudo-elements with no role, so the measurement is never carried by color alone. Each thumb is a real native range input, so it stays operable under forced-colors: active (Windows High Contrast). See the slider and field pages and the accessibility guide.