Range
.range styles a real <input type="range">. A slider
is the one control CSS cannot style with ordinary properties: the track and the
thumb are vendor pseudo-elements, and the two engines do not agree on their names or
their box model. Deck writes both, and the differences between them are the reason
this page exists.
When to use it
Use a slider when the reader is choosing approximately — a price ceiling, a volume,
an opacity — and the exact number matters less than the position. If the number
matters, pair it with a readout, or use .number instead.
The input is block-size: var(--tap) —
44px — with a 6px track drawn inside it,
so the grab area is comfortable on a phone while the slider still looks thin.
<div class="field" style="max-inline-size:24rem">
<label class="label" for="ex-vol">Volume</label>
<input class="range" type="range" id="ex-vol" min="0" max="100" value="60">
</div>
<div class="field" style="max-inline-size:24rem">
<label class="label" for="ex-vol">Volume</label>
<input class="range" type="range" id="ex-vol" min="0" max="100" value="60">
</div>
Two engines, two sets of rules
Every declaration is written twice:
| Part | Chromium and WebKit | Firefox |
|---|---|---|
| Track | ::-webkit-slider-runnable-track |
::-moz-range-track |
| Thumb | ::-webkit-slider-thumb |
::-moz-range-thumb |
They cannot be combined into one selector: a rule with an unrecognised vendor pseudo-element is dropped entirely by the other engine, taking the valid half with it. That is why the source repeats itself here and nowhere else.
The two also differ in box model. WebKit's thumb needs
margin-block-start: -8px to centre on the track; Firefox centres it
automatically, so the Firefox rule has no margin. A thumb that looks correct in one
browser and sits low in the other is almost always this line.
Two handles
.range-pair is the interesting one. It stacks two real
<input type="range"> elements in the same grid cell, hides both
tracks, and draws its own: a grey ::before for the full width and a
brand-coloured ::after positioned from
--lo and --hi.
The inputs get pointer-events: none and only their thumbs get it back,
so a click between the handles does not jump one of them. Both inputs stay real, so
both submit with the form and both are reachable by keyboard.
src/js/deck-extras.js wires the rest: on every
input event it writes --lo and --hi as
percentages, refuses to let the handles cross, fills any
.range-readout beside the pair, and fires a
deck:change event carrying { min, max }. The CSS draws;
the script keeps the drawing honest.
<div class="field" style="max-inline-size:26rem">
<span class="label" id="ex-price-label">Price range</span>
<div class="range-pair" data-gap="5" style="--lo:20;--hi:70">
<input type="range" min="0" max="100" value="20" data-prefix="$" aria-label="Minimum price">
<input type="range" min="0" max="100" value="70" data-prefix="$" aria-label="Maximum price">
</div>
<div class="range-readout"><span>$20</span><span>$70</span></div>
</div>
<div class="field" style="max-inline-size:26rem">
<span class="label" id="ex-price-label">Price range</span>
<div class="range-pair" data-gap="5" style="--lo:20;--hi:70">
<input type="range" min="0" max="100" value="20" data-prefix="$" aria-label="Minimum price">
<input type="range" min="0" max="100" value="70" data-prefix="$" aria-label="Maximum price">
</div>
<div class="range-readout"><span>$20</span><span>$70</span></div>
</div>
--lo and --hi are percentages of the track written as bare
numbers. Setting them in the markup, as the example does, is only the starting
position — from the first interaction onwards deck-extras.js owns them.
If you are not loading Deck's JavaScript, the fill is whatever you last wrote and
the two handles will drift out of step with it.
data-gap on the pair is the minimum distance the script keeps between
the handles. With data-gap="10" on a 0-100 scale the two can never come
within ten of each other, and whichever handle the reader is holding is the one that
gets pushed back — so dragging never yanks the handle out from under the pointer.
Readout and ticks
.range-readout is a tabular-figure display for the current value.
Beside a .range-pair it is filled in for you: the script writes the low
value into its first child and the high value into its second, so it needs exactly
two children and it has to be a sibling of the pair. data-prefix on
either input is prepended — data-prefix="$" gives "$20" rather than
"20".
Beside a single .range it is styling only, and an
<output> you update yourself.
.range-ticks is a row of labels beneath the track and is never
populated by anything.
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ex-budget">Monthly budget</label>
<input class="range" type="range" id="ex-budget" min="0" max="500" step="50" value="250">
<output class="range-readout" for="ex-budget">$250</output>
<div class="range-ticks">
<span>$0</span>
<span>$250</span>
<span>$500</span>
</div>
</div>
<div class="field" style="max-inline-size:26rem">
<label class="label" for="ex-budget">Monthly budget</label>
<input class="range" type="range" id="ex-budget" min="0" max="500" step="50" value="250">
<output class="range-readout" for="ex-budget">$250</output>
<div class="range-ticks">
<span>$0</span>
<span>$250</span>
<span>$500</span>
</div>
</div>
<output> is an implicit live region, so a screen reader announces
the new value as the slider moves — without any aria-live of your own.
That is why the example uses it rather than a <span>.
Classes
Generated from the stylesheet source by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.range |
deck.components |
A slider. The track and thumb are vendor pseudo-elements with different names and different box models per engine, so every declaration is written twice — WebKit needs a negative block-start margin to centre the thumb and Firefox does not. | 06-forms.css:288 |
.range-pair |
deck.components |
Two stacked native range inputs. Real inputs mean real keyboard support and a real form post; the track between them is drawn from --lo and --hi. | 23-inputs.css:272 |
.range-readout |
deck.components |
The current value of a slider. Put it on an <output>, which is an implicit live region, so the value is announced as the slider moves. | 23-inputs.css:330 |
.range-ticks |
deck.components |
Ticks under a single or paired range | 23-inputs.css:339 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--tap |
44px |
|
--ink-0 |
oklch(100% 0 0) |
|
--ink-200 |
oklch(91.2% calc(var(--chroma-neutral) * 1.8) var(--hue-neutral)) |
|
--brand-500 |
oklch(60.8% var(--chroma-brand) var(--hue-brand)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--r-full |
999px |
|
--shadow-2 |
0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07) |
Accessibility
-
The input is real, so the keyboard works. Arrow keys step by
step, Page Up and Page Down jump, Home and End go to the ends. None of that is Deck's doing and none of it can be lost by styling. -
Focus is the global ring. There is no
:focus-visiblerule on.rangeitself, so the browser's default focus indication applies to the input as a whole. The thumb does not get its own ring, which makes focus less obvious than on other Deck controls. Worth adding inapp.componentsif sliders are important on your page. -
A slider needs a visible label and a visible value. The position
of a handle is not readable to anyone who cannot see it precisely, and
aria-valuetextis what makes "250" into "$250 per month". Use<output>so the value is announced as it changes. -
.range-pairneeds two labels. Each input is a separate control and each needs its own name — "Minimum price" and "Maximum price". A single label on the wrapper leaves both handles unnamed. -
The handles cannot cross, but only with the JavaScript loaded.
deck-extras.jsclamps them on everyinputevent, which is early enough to stay ahead of the drag. Without the script there is nothing in the CSS to stop the maximum being dragged below the minimum, and the fill inverts. -
Two handles in one place are hard to separate. When both sit at
the same value the reader cannot tell which one they are about to grab, and a
screen-reader user has two identically-positioned controls.
data-gapprevents the overlap; distinctaria-labels are what make them distinguishable. - Touch target. The input is 44px tall and the thumb is 22px, so the grab area is the full height of the control rather than the size of the visible handle.
-
.range-tickslabels are decorative. They are not associated with the slider's values in any way a screen reader can use. They help a sighted reader read the scale and do nothing for anyone else.
Right to left
The browser mirrors a range input under dir="rtl" on its own — the
minimum moves to the right and arrow keys follow the writing direction — and Deck
adds nothing, because the track and thumb rules are symmetrical.
.range-pair's fill uses margin-inline-start, so it is
measured from the correct edge in both directions.
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="ex-rtl-range">مستوى الصوت</label>
<input class="range" type="range" id="ex-rtl-range" min="0" max="100" value="60">
</div>
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="ex-rtl-range">مستوى الصوت</label>
<input class="range" type="range" id="ex-rtl-range" min="0" max="100" value="60">
</div>
Reduced motion
The only movement is scale: 1.08 on the WebKit thumb while it is being
dragged, which is a response to the reader's own gesture rather than an animation
they did not ask for. It is not transitioned, so
prefers-reduced-motion has nothing to collapse.
Printing
Deck's print stylesheet has no rule for .range. A slider prints as
whatever the browser draws for it, which is usually the track and thumb without the
brand colour. If a printed form needs the value, put it in a
<output> beside the slider — the readout prints as text.
Overriding it
Remember to write both engines. Overriding only the WebKit pseudo-element leaves Firefox on Deck's default, and the mismatch is easy to miss.
@layer app.components {
.range::-webkit-slider-runnable-track { block-size: 10px; }
.range::-moz-range-track { block-size: 10px; }
/* WebKit needs the offset recomputed when the track height changes */
.range::-webkit-slider-thumb { margin-block-start: -6px; }
/* A focus ring on the thumb, which Deck does not ship */
.range:focus-visible::-webkit-slider-thumb { box-shadow: var(--ring); }
.range:focus-visible::-moz-range-thumb { box-shadow: var(--ring); }
}
When not to use it
-
Not when the exact value matters. Hitting 37 on a 0–100 slider is
hard with a mouse and harder with a thumb. Use
.number, or pair the slider with a number input that edits the same value. -
Not for a small set of choices. A slider with
stepand four positions is a.segmentedcontrol with extra imprecision. -
Not for an on/off setting. That is
.switch. - Not for a value with no natural range. A slider needs a floor and a ceiling that mean something to the reader. "Between 0 and 1,000,000" is not a scale anyone can aim at.
-
.range-pairis not a substitute for two fields. If the reader knows the numbers they want — a date range, a price they typed off an invoice — two inputs are faster and exact. The pair is for exploring, not for entering. - Not without a visible value. A bare slider tells the reader nothing about what they have chosen. That is the most common slider defect and it is a markup problem, not a styling one.