Navigation

v0.0.4
Forms

Range

Two values on one track, with ink between them.

On this page

A range picks two values on one scale: the lowest and the highest price, the start and the end of a clip. It is two native sliders in one group. The group draws a single track and fills it with ink between the thumbs; each input keeps its own keys, its own label and its own value in the form.

Range

<div class="flex flex-col gap-sm max-w(360) mx-auto">  <div class="flex items-baseline justify-between text-sm">    <span class="fg-text-strong" id="price">Price</span>    <output class="fg-text-muted" id="price-output">$120 to $380</output>  </div>  <div class="range" role="group" aria-labelledby="price">    <input type="range" class="slider range-start" min="0" max="500" step="10" value="120" aria-label="Lowest price">    <input type="range" class="slider range-end" min="0" max="500" step="10" value="380" aria-label="Highest price">  </div></div><script>  (function () {  var range = document.querySelector('.range')  var output = document.getElementById('price-output')  range.addEventListener('input', function () {    var values = Array.from(range.querySelectorAll('.slider'), function (slider) { return Number(slider.value) })    output.textContent = '$' + Math.min.apply(null, values) + ' to $' + Math.max.apply(null, values)  })  })()</script>
Drag either thumb, or focus one and use the arrow keys. The script only writes the two numbers above the track.

#No script for the fill

Each thumb is a view timeline inside its own input, the way a single slider reads its fill. The group takes both timelines with timeline-scope, runs one animation on each and so knows two percentages. It paints from the lesser to the greater, which means the thumbs may cross: the fill is still between them, and the lower of the two values is still the start of the range.

Read the pair the same way in your form handler: the smaller value is the lower bound. If the two must keep a distance, clamp them on input; nothing in the drawing depends on it.

Where the browser has no scroll-driven animations the track is a plain well under the two thumbs, and both inputs work as they always have. Chromium and Safari 26 draw the fill.

#How the thumbs share a track

The two inputs lie in the same grid cell. Each gives up its own track and takes the pointer only on its thumb, so a press between the thumbs reaches neither and a thumb is never hidden behind the other input's empty track. The one being moved comes to the front. A press on the bare track does nothing: with two thumbs it could not say which one it meant.

#Disabled

Disable both inputs. The thumbs and the fill fade together.

Disabled

<div class="range max-w(360) mx-auto" role="group" aria-label="Seats, set by your plan">  <input type="range" class="slider range-start" value="20" aria-label="Fewest seats" disabled>  <input type="range" class="slider range-end" value="60" aria-label="Most seats" disabled></div>

#Accessibility

  • Give the group role="group" and a name, and give each input its own aria-label: "Lowest price" and "Highest price", not "Start" and "End".
  • Print the two values near the track. A thumb does not show its number.
  • Each thumb is a tab stop with the keys of a native slider. On touch screens a thumb is 24px.
  • When the exact numbers matter more than the feel of the scale, offer two steppers or two number fields as well.

#Classes

ClassDescription
rangeThe group around the two sliders: one track, filled between its two thumbs. Give it role="group" and a name
range-startOn one of the two slider inputs: names its thumb for the fill
range-endOn the other slider input: names its thumb for the fill

#Custom properties

PropertyDefaultDescription
--range-track.375remTrack thickness
--range-colorvar(--color-surface-inverse)Fill color

#Source

@master/ui/components/range.css
/* * Range: two values on one track: a lowest and a highest price, the start and the end of a clip. * It is two native range inputs, each a slider, laid over each other in one group. The group draws the track * and fills it with ink between the two thumbs; the inputs keep only their thumbs, and only the thumbs take * the pointer, so either one can be reached where they stand close. Each input keeps its own keys, its own * label and its own value in the form. * The fill needs no script. Each thumb is a view timeline in its own input, and the group reads both: it * paints from the lesser reading to the greater, so the thumbs may cross and the fill is still between them. * Without scroll-driven animations the track stays a plain well under the two thumbs. */@property --range-from {  syntax: '<percentage>';  inherits: true;  initial-value: 0%;}@property --range-to {  syntax: '<percentage>';  inherits: true;  initial-value: 100%;}@layer components {  .range {    --range-track: .375rem;    --range-color: var(--color-surface-inverse);    /* Until a timeline says otherwise the fill is empty: both ends stand at the start. */    --range-fill-from: 0%;    --range-fill-to: 0%;    position: relative;    display: grid;    min-width: 0;    margin: 0;    padding: 0;    border: 0;    /* The track: one well for both thumbs, filled between them. */    &::before {      content: '';      position: absolute;      inset: 50% 0 auto;      height: var(--range-track);      translate: 0 -50%;      border-radius: 999px;      background:        linear-gradient(90deg, transparent var(--range-fill-from), var(--range-color) 0, var(--range-color) var(--range-fill-to), transparent 0)        var(--color-surface-inset);      box-shadow: var(--shadow-pressed);      pointer-events: none;    }    &:dir(rtl)::before {      background:        linear-gradient(270deg, transparent var(--range-fill-from), var(--range-color) 0, var(--range-color) var(--range-fill-to), transparent 0)        var(--color-surface-inset);    }    &:has(.slider:disabled)::before {      opacity: .5;    }    /*     * The two sliders share one cell. Each gives up its own track and takes the pointer only on its thumb.     * The one being moved comes to the front, so a thumb that was under the other can be picked up again.     */    .slider {      --slider-color: transparent;      /* Positioned, so the thumbs stand over the group's track. */      position: relative;      grid-area: 1 / 1;      pointer-events: none;      &:is(:active, :focus-visible) {        z-index: 1;      }      &::-webkit-slider-runnable-track {        background: transparent;        box-shadow: none;      }      &::-webkit-slider-thumb {        pointer-events: auto;      }      &::-moz-range-track {        background: transparent;        box-shadow: none;      }      &::-moz-range-progress {        background: transparent;      }      &::-moz-range-thumb {        pointer-events: auto;      }    }    @media (forced-colors: active) {      --range-color: Highlight;      &::before {        forced-color-adjust: none;        background:          linear-gradient(90deg, transparent var(--range-fill-from), Highlight 0, Highlight var(--range-fill-to), transparent 0)          Canvas;        box-shadow: inset 0 0 0 1px CanvasText;      }    }  }  /*   * The fill. Each thumb names a view timeline in its input, as a slider's does, and the group makes both   * names its own, runs one animation on each and reads two percentages.   */  @supports (view-timeline: --range-start inline) and (timeline-scope: --range-start) {    .range {      --range-fill-from: min(var(--range-from), var(--range-to));      --range-fill-to: max(var(--range-from), var(--range-to));      timeline-scope: --range-start, --range-end;      animation-name: range-from, range-to;      animation-timing-function: linear;      animation-fill-mode: both;      animation-timeline: --range-start, --range-end;      animation-range: contain;      &:dir(rtl) {        animation-direction: reverse;      }      /* A slider inside a range hands its timeline to the group and runs no fill of its own. */      .slider {        timeline-scope: none;        animation-name: none;      }      .range-start::-webkit-slider-thumb {        view-timeline: --range-start inline;        view-timeline-inset: var(--slider-bleed);      }      .range-end::-webkit-slider-thumb {        view-timeline: --range-end inline;        view-timeline-inset: var(--slider-bleed);      }    }  }  /* The two ends. The classes only name the thumbs for the fill; which value is the lower is the form's to read. */  .range-start, .range-end {    min-width: 0;  }}@keyframes range-from {  from {    --range-from: 100%;  }  to {    --range-from: 0%;  }}@keyframes range-to {  from {    --range-to: 100%;  }  to {    --range-to: 0%;  }}