Skip to content

Component

range

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and range.css. No scripts.

Source

Copy both files, or run npx nojsui add range.

Source for range
<!-- .sk-range — a real <input type="range">, themed, with native tick marks.

     There is deliberately no value bubble. Dragging a range updates the value
     IDL property and never touches the `value` content attribute, which is
     what attr() reads — measured in Chromium, after ten ArrowRight presses on
     value="50" the IDL value is "60" and attr(value) is still 50. A live
     bubble therefore needs script to mirror one onto the other, and this kit
     does not ship script. See the README and docs/polyfills.md, which has the
     opt-in for consumers who want one.

     The ticks come from <datalist>: entirely native, and rendered by all three
     engines (differently — Chromium and Firefox draw marks below the track,
     WebKit draws dots on it). Nothing in this component's CSS draws them. -->
<div class="sk-range">
  <label class="sk-range__label" for="sk-range-volume">Volume</label>
  <input
    class="sk-range__input"
    id="sk-range-volume"
    name="volume"
    type="range"
    min="0"
    max="100"
    step="1"
    value="60"
  />
  <div class="sk-range__scale" aria-hidden="true">
    <span>0</span>
    <span>100</span>
  </div>
</div>

<!-- A stepped range with named steps. The names are shown, but the value a
     screen reader announces is the NUMBER — naming the current step needs
     aria-valuetext, which cannot be updated without script. If the names
     matter more than the ordering, a <select> is the better control; the
     README says so rather than leaving it to be discovered. -->
<div class="sk-range">
  <label class="sk-range__label" for="sk-range-quality">Export quality</label>
  <input
    class="sk-range__input"
    id="sk-range-quality"
    name="quality"
    type="range"
    min="0"
    max="3"
    step="1"
    value="2"
    list="sk-range-quality-ticks"
  />
  <datalist id="sk-range-quality-ticks">
    <option value="0" label="Draft"></option>
    <option value="1" label="Low"></option>
    <option value="2" label="Good"></option>
    <option value="3" label="Best"></option>
  </datalist>
  <div class="sk-range__scale" data-steps="4" aria-hidden="true">
    <span>Draft</span>
    <span>Low</span>
    <span>Good</span>
    <span>Best</span>
  </div>
</div>
/* .sk-range — a themed native range with native tick marks.

   WHAT THIS COMPONENT DELIBERATELY DOES NOT HAVE: a value bubble. Dragging a
   range updates the `value` IDL property and never touches the `value` content
   attribute, and attr() reads the attribute. Measured in Chromium: after ten
   ArrowRight presses on value="50", input.value is "60" while
   getAttribute("value") and attr(value) in CSS are both still 50. A live
   bubble needs a script to mirror one onto the other, so it is an opt-in in
   docs/polyfills.md rather than a component. ADR 0017.

   WHAT IT DOES NOT DO EITHER: `appearance: none`. Restyling a range's track
   and thumb means ::-webkit-slider-thumb and ::-moz-range-thumb — vendor
   pseudo-elements, not standard, and two of them to keep in sync forever. A
   native range coloured with accent-color is a good control in all three
   engines, so the component themes it and leaves the rest to the platform.
   That is also what keeps the tick marks: they are drawn by the UA, and
   appearance: none removes them along with everything else.

   Specs:
   - accent-color .......... https://drafts.csswg.org/css-ui-4/#widget-accent
   - <datalist> for range .. https://html.spec.whatwg.org/multipage/input.html#range-state-(type=range) */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-range {
  --_accent: var(--sk-range-accent, var(--sk-color-accent));
  --_label-fg: var(--sk-range-label-fg, var(--sk-color-text));
  --_scale-fg: var(--sk-range-scale-fg, var(--sk-color-text-subtle));
  --_gap: var(--sk-range-gap, var(--sk-space-2xs));

  display: grid;
  gap: var(--_gap);
}

.sk-range__label {
  color: var(--_label-fg);
  font-size: var(--sk-text-sm);
}

.sk-range__input {
  /* The whole of the theming. base.css already sets accent-color from the
     palette on :root; this lets one range differ from the rest without
     touching a global token. */
  accent-color: var(--_accent);

  /* Fills its column rather than the UA's default width, so the scale below
     lines up with the track ends. */
  inline-size: 100%;
  margin: 0;
}

/* The focus ring comes from base.css and must not be overridden here. */

/* The visible step names. aria-hidden because they duplicate information the
   control already carries — and because what a screen reader announces is the
   numeric value, not these names (README). Laid out to align with the track:
   the first and last labels sit at the ends, the rest are distributed. */
.sk-range__scale {
  display: flex;
  justify-content: space-between;
  color: var(--_scale-fg);
  font-size: var(--sk-text-2xs);
}

/* Named steps have to line up with the UA's tick marks, and equal-width
   columns do not: four 1fr columns centre their labels at 12.5/37.5/62.5/87.5%
   while the ticks sit at 0/33/67/100%, which is visibly wrong by about 4% of
   the track.

   So each label is a zero-width point that its text overflows equally on both
   sides. space-between then places those points at exactly 0%, 33%, 67% and
   100%, and the text is centred on each one. The first and last align to their
   own edge instead, so they do not hang off the ends.

   Residual error: the UA insets the track by half a thumb at each end, and the
   thumb's width is not exposed to CSS, so the outer labels sit a few pixels
   outside their marks. Nothing in CSS can close that gap; the tick marks
   themselves are the precise indicator and these are the names for them. */
.sk-range__scale[data-steps] {
  display: flex;
  justify-content: space-between;
}

.sk-range__scale[data-steps] > span {
  display: grid;
  justify-items: center;
  inline-size: 0;
  white-space: nowrap;
}

.sk-range__scale[data-steps] > :first-child {
  justify-items: start;
}

.sk-range__scale[data-steps] > :last-child {
  justify-items: end;
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour — the demo stacks two ranges.
--------------------------------------------------------------------------- */

.sk-range + .sk-range {
  margin-block-start: var(--sk-space-xl);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
accent-colorlimited93939226.29226.2

Usage

A real <input type="range">, coloured from the token palette, with optional native tick marks.

<div class="sk-range">
  <label class="sk-range__label" for="volume">Volume</label>
  <input class="sk-range__input" id="volume" type="range" min="0" max="100" value="60" />
</div>

Add ticks with a <datalist>. The browser draws them; nothing in this component’s CSS does:

<input class="sk-range__input" id="quality" type="range" min="0" max="3" list="quality-ticks" />
<datalist id="quality-ticks">
  <option value="0" label="Draft"></option>
  <option value="3" label="Best"></option>
</datalist>

There is no value bubble, and there cannot be one

The obvious feature for this component is a bubble showing the current value. It is not here, and the reason is worth stating precisely, because it is the kind of thing that looks like an oversight.

Dragging a range updates the value IDL property and never touches the value content attribute. attr() reads the content attribute. Measured in Chromium: after ten ArrowRight presses on <input type="range" value="50">, input.value is "60" while getAttribute("value") — and attr(value) in CSS — are both still 50. There is no CSS way to read a range’s live value.

So a live bubble needs a script to mirror the IDL property onto the attribute, and this kit does not ship script. docs/polyfills.md has the handful of lines if you want one in your own project.

A bubble showing only the default value was considered and rejected: it would display a number that stops matching the slider the instant anyone touches it, which is the same class of lie as a progress bar frozen at 100%. ADR 0017 has the full reasoning and records the deviation from the PRD.

appearance: none is also deliberately absent

Restyling a range’s track and thumb means ::-webkit-slider-thumb and ::-moz-range-thumb — vendor pseudo-elements, not standard, and two of them to keep in sync indefinitely. A native range tinted with accent-color is a good, familiar control in all three engines.

It also keeps the tick marks. They are drawn by the user agent, and appearance: none removes them along with the rest of the native rendering.

How it works

accent-color tints the filled part of the track and the thumb. base.css already sets it from the palette on :root; the --sk-range-accent knob is what lets one range differ from the rest without touching a global token.

The tick marks come entirely from <datalist>. All three engines render them, and they render them differently — verified 2026-08-21:

EngineTick rendering
ChromiumSmall marks below the track
FirefoxSmall marks below the track
WebKitDots on the track itself

That difference is the platform’s, not the component’s, and it is why the visual snapshots differ per engine.

The .sk-range__scale row places each name as a zero-width point that its text overflows equally, so space-between lands the points at exactly 0%, 33%, 67% and 100% of the row. Equal-width columns would put them at 12.5/37.5/62.5/87.5% instead — visibly wrong against the ticks. One gap remains and cannot be closed: the user agent insets the track by half a thumb at each end, and the thumb’s width is not exposed to CSS, so the names sit a few pixels outside their marks. The tick marks are the precise indicator; the names label them.

Keyboard contract

Entirely native.

KeyBehaviour
TabMoves focus to the slider
/ Decreases by one step
/ Increases by one step
Home / EndJumps to min / max
Page Up / Page DownLarger jump, size chosen by the engine

Verified manually in VoiceOver and NVDA: yes — announced as a slider with its label, its numeric value, and its range.

Accessibility notes

The accessible name is the <label for>. The .sk-range__scale row is aria-hidden because it duplicates what the control already conveys.

A screen reader announces the number, not the step’s name. In the “Export quality” demo the visible scale reads Draft / Low / Good / Best, but the announced value is 2. Naming the current step requires aria-valuetext, which has to change as the value changes — and that needs script, for the same reason the bubble does.

If the names matter more than the ordering does, a <select> is the better control: it announces the chosen option’s text, it is smaller on a phone, and it does not ask the user to hit a target. Use a range when the value is genuinely continuous or the ordering is the point.

The focus ring comes from base.css and is not overridden. Because the component does not use appearance: none, the thumb also keeps whatever platform focus affordance the engine provides.

Degradation

Feature usedBaseline statusBehavior without it
accent-colorChrome 93+, Firefox 92+, Safari 26.2+The range renders in the platform’s own accent colour instead of the kit’s. A completely normal control; only the tint differs.
<datalist> on a rangewidelyNo tick marks. The slider still has the same min, max and step, so behaviour is identical and only the visual guide is missing.

Nothing here is a floor: with neither feature you still get a working, correctly labelled native slider.

Theming

PropertyDefaultControls
--sk-range-accent--sk-color-accentTrack fill and thumb colour
--sk-range-label-fg--sk-color-textLabel text
--sk-range-scale-fg--sk-color-text-subtleThe step-name row under the track
--sk-range-gap--sk-space-2xsSpace between label, slider and scale