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.

A real range input, styled
<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:

Vendor pseudo-elements for a range input
PartChromium and WebKitFirefox
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.

Drag either handle: the fill, the readout and both values keep up
Price range
$20$70
<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.

The readout is an <output>, which is announced when it changes
$250
$0 $250 $500
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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-visible rule on .range itself, 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 in app.components if 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-valuetext is what makes "250" into "$250 per month". Use <output> so the value is announced as it changes.
  • .range-pair needs 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.js clamps them on every input event, 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-gap prevents the overlap; distinct aria-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-ticks labels 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.

The browser mirrors it; Deck stays out of the way
<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 step and four positions is a .segmented control 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-pair is 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.