Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Range Slider

A styled CSS range input using appearance: none, vendor prefixed thumb pseudo-elements, and track styling with gradients.

Level
Intermediate
Published
Updated
Reading time
9 min

Try it

3 live demos

Your browser can't run this demo

It needs :has(), round() and scroll-driven animations, which this browser doesn't support yet. It works in Chrome 125+, Edge 125+, Firefox 159+ and Safari 26+.

Needs :has(), round() and scroll-driven animations

Styled track and thumb

Other ways to build it

Build it

6 snippets to copy

CSS Range Slider

HTML

<div class="rs-group">
  <label class="rs-row">
    <span class="rs-name">Volume</span>
    <span class="rs-value" aria-hidden="true"></span>
    <span class="rs-scale" aria-hidden="true">0 to 100</span>
    <span class="rs-track">
      <input type="range" min="0" max="100" value="40">
    </span>
  </label>
  <label class="rs-row rs-ring">
    <span class="rs-name">Brightness</span>
    <span class="rs-value" data-unit="%" aria-hidden="true"></span>
    <span class="rs-scale" aria-hidden="true">0 to 100%</span>
    <span class="rs-track">
      <input type="range" min="0" max="100" value="70">
    </span>
  </label>
  <label class="rs-row rs-steps">
    <span class="rs-name">Rating</span>
    <span class="rs-value" data-unit=" of 5" aria-hidden="true"></span>
    <span class="rs-scale" aria-hidden="true">1 to 5</span>
    <span class="rs-track">
      <input type="range" min="1" max="5" step="1" value="4">
    </span>
  </label>
</div>

CSS

.rs-group {
  display: grid;
  gap: 1.4rem;
  justify-content: center;
  font-family: system-ui, sans-serif;
}

/* The track is the input itself */
input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 220px;
  height: 6px;
  background: #2a2a2d;
  border-radius: 999px;
  outline: none;
  cursor: pointer;
}

/* The thumb needs one rule per engine */
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 22px;
  height: 22px;
  background: #b8ff57;
  border-radius: 50%;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
  transition: box-shadow .2s;
}

input[type="range"]:hover::-webkit-slider-thumb {
  box-shadow: 0 1px 10px rgba(0, 0, 0, .5);
}

input[type="range"]::-moz-range-thumb {
  width: 22px;
  height: 22px;
  background: #b8ff57;
  border: none;
  border-radius: 50%;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}

input[type="range"]::-moz-range-track {
  background: #2a2a2d;
  height: 6px;
  border-radius: 999px;
}

/* Each slider with its name and value above it */
.rs-row {
  --rs-min: 0;
  --rs-max: 100;
  /* -1 means no reading: the animation below replaces it
     with the value only where its timeline is running */
  --rs-v: -1;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  gap: .2rem .75rem;
  width: 220px;
  timeline-scope: --rs-thumb;
}

.rs-name {
  font-size: .8rem;
  font-weight: 600;
  color: #88888f;
}

/* the number and the fallback scale share one cell */
.rs-value,
.rs-scale {
  grid-row: 1;
  grid-column: 2;
  justify-self: end;
}

.rs-value {
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: #b8ff57;
  opacity: clamp(0, var(--rs-v) + 1, 1);
}

.rs-value::after {
  counter-reset: rs-n round(var(--rs-v));
  content: counter(rs-n) attr(data-unit);
}

.rs-scale {
  font-size: .72rem;
  color: #88888f;
  opacity: clamp(0, -1 * var(--rs-v), 1);
}

/* The thumb's place inside this clipping box is the value,
   so the box is exactly as wide as the input. The padding
   is room for the thumb above and below the track */
.rs-track {
  grid-column: 1 / -1;
  display: block;
  overflow: hidden;
  padding-block: 12px;
  border-radius: 999px;
}

.rs-track input[type="range"] {
  display: block;
  margin: 0;
}

/* the box clips the input's own focus ring, so it goes on the box */
.rs-track:has(input:focus-visible) {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

/* The thumb names a view timeline: how far it sits
   across the box. One rule per engine, as above */
.rs-track input[type="range"]::-webkit-slider-thumb {
  view-timeline: --rs-thumb inline;
}

.rs-track input[type="range"]::-moz-range-thumb {
  view-timeline: --rs-thumb inline;
}

@property --rs-v {
  syntax: "<number>";
  initial-value: 0;
  inherits: true;
}

/* At the right end the thumb has fully entered the box, at
   the left end it is about to exit. Keyframes placed by those
   ranges are ignored by a browser that runs the animation on
   plain time, so --rs-v stays -1 instead of a wrong number */
@keyframes rs-read {
  entry 100% { --rs-v: var(--rs-max); }
  exit 0% { --rs-v: var(--rs-min); }
}

@supports (animation-timeline: --rs-thumb) {
  .rs-row {
    animation: rs-read linear both;
    animation-timeline: --rs-thumb;
  }
}

/* Style two: a hairline track and a ring for a thumb */
.rs-ring input[type="range"] {
  height: 2px;
  background: #88888f;
}

.rs-ring input[type="range"]::-webkit-slider-thumb {
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  border: 4px solid #b8ff57;
  background: #0c0c0d;
  box-shadow: none;
}

.rs-ring input[type="range"]::-moz-range-thumb {
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  border: 4px solid #b8ff57;
  background: #0c0c0d;
  box-shadow: none;
}

.rs-ring input[type="range"]::-moz-range-track {
  height: 2px;
  background: #88888f;
}

/* Style three: a thick track with a dot at each of its five
   stops and a square thumb. The thumb's center travels
   220px - 22px = 198px: a dot every 49.5px from 11px */
.rs-steps {
  --rs-min: 1;
  --rs-max: 5;
}

.rs-steps input[type="range"] {
  height: 12px;
  background:
    radial-gradient(circle, #88888f 0 3px, transparent 3.5px)
      -13.75px 50% / 49.5px 100% repeat-x,
    #1c1c1e;
  border-radius: 4px;
}

.rs-steps input[type="range"]::-webkit-slider-thumb {
  border-radius: 6px;
}

.rs-steps input[type="range"]::-moz-range-thumb {
  border-radius: 6px;
}

.rs-steps input[type="range"]::-moz-range-track {
  height: 12px;
  background:
    radial-gradient(circle, #88888f 0 3px, transparent 3.5px)
      -13.75px 50% / 49.5px 100% repeat-x,
    #1c1c1e;
  border-radius: 4px;
}

Track that fills in behind the thumb

Firefox has a pseudo-element for the filled section of a track and WebKit does not, so the two engines need different answers. Firefox gets ::-moz-range-progress. WebKit gets a box shadow on the thumb with a large negative horizontal offset and a spread wide enough to reach the far end of the track, clipped back to the track by overflow: hidden on the input. Because the shadow is painted relative to the thumb, it follows the handle live while it is dragged, which a gradient using a fixed percentage cannot do. Drag the handle.

HTML

<input type="range" class="range-fill" min="0" max="100" value="55" aria-label="Volume">

CSS

.range-fill {
  height: 22px;
  background: #1c1c1e;
  border-radius: 999px;
  /* clips the thumb's shadow back to the track */
  overflow: hidden;
}

.range-fill::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #0c0c0d;
  border: 3px solid #b8ff57;
  box-sizing: border-box;
  /* offset left, spread wide: paints the filled part of the track */
  box-shadow: -220px 0 0 209px #b8ff57;
}

.range-fill::-moz-range-track {
  height: 22px;
  background: #1c1c1e;
  border-radius: 999px;
}

/* Firefox has a real pseudo-element for this, so no shadow is needed */
.range-fill::-moz-range-progress {
  height: 22px;
  background: #b8ff57;
  border-radius: 999px;
}

.range-fill::-moz-range-thumb {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: #0c0c0d;
  border: 3px solid #b8ff57;
  box-sizing: border-box;
}

Stepped slider with tick marks

A step of 20 turns a continuous slider into six positions, and the marks are drawn straight onto the track with a repeating gradient. The alignment is the part that takes care: the thumb's center travels the track width minus the thumb width, so the gradient is sized to calc(100% - 22px) and pushed 11 pixels in from the left, which is half a thumb. Without that the marks and the stopping points drift apart at both ends. The focus ring the base rule removed with outline: none is added back here.

HTML

<input type="range" class="range-ticks"
       min="0" max="100" step="20" value="60"
       aria-label="Satisfaction">

CSS

.range-ticks {
  height: 10px;
  background-color: #2a2a2d;
  background-image:
    /* the mark at zero */
    linear-gradient(to right, #88888f 0 2px, transparent 2px),
    /* the five that follow, one at the end of each step */
    repeating-linear-gradient(to right,
      transparent 0 calc(20% - 2px),
      #88888f calc(20% - 2px) 20%);
  /* the thumb center only travels width minus one thumb */
  background-size: calc(100% - 22px) 100%;
  background-position: 11px 0;
  background-repeat: no-repeat;
  border-radius: 999px;
}

/* put back the ring that outline: none took away */
input[type="range"]:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 4px;
}

Understand it

How it works and 8 properties

A styled CSS range slider is one of the few controls where appearance: none is not enough on its own. Removing the native rendering gives you the track, because the track is the <input> element itself, but the thumb lives inside the browser's shadow DOM and can only be reached through a vendor prefixed pseudo-element. WebKit calls it ::-webkit-slider-thumb and Firefox calls it ::-moz-range-thumb, and there is still no standard name that both accept.

Those two rules cannot be combined into one selector. An unrecognized pseudo-element invalidates the whole selector list, so a rule listing both would be thrown away by both engines. Below the default version are two more: a track that fills in behind the thumb as it moves, and a stepped slider with tick marks drawn from a repeating gradient.

How it works

appearance:none removes the browser default. The track is styled directly on the <input> element. The thumb is styled via the vendor-specific ::-webkit-slider-thumb and ::-moz-range-thumb pseudo-elements. Both engines need separate rules, because they do not share a standard pseudo-element name yet.

Duplicating the thumb rule is not a style choice, it is a requirement of how selector parsing works. When a browser meets a selector it does not understand, it discards the entire rule, and a selector list is one selector for that purpose. Writing ::-webkit-slider-thumb, ::-moz-range-thumb { } means Firefox throws the rule away over the WebKit half and WebKit throws it away over the Firefox half, so neither gets a styled thumb. Two separate blocks with identical declarations is the only version that works.

WebKit also needs -webkit-appearance: none a second time, on the thumb itself. Removing it from the input clears the native track but leaves the thumb drawn by the platform, and until the thumb is reset it ignores width, height and background. This is the single most common reason a slider ends up with a nicely styled track and a stock gray handle.

The two engines disagree about the track as well. In WebKit the input's own background is the visible track, which is why the base rule sets height: 6px and a border radius directly on the input. Firefox draws a separate ::-moz-range-track box, so the same values have to be repeated there. Firefox additionally gives you ::-moz-range-progress, a filled section from the start of the track to the thumb, which WebKit has no equivalent for.

That missing WebKit fill is what the first variant works around. A box shadow with a large negative horizontal offset and a matching spread paints a slab to the left of the thumb, and overflow: hidden on the input clips it to the track. The result follows the thumb live while it is dragged, with no script keeping a variable in sync, which is what a linear-gradient fill using a custom property cannot do on its own.

The base snippet sets outline: none on the input. That is worth undoing. The stylesheet on this page adds a :focus-visible rule back, because a slider with no visible focus is unusable with a keyboard even though the arrow keys still change the value. Removing an outline is only safe when something visible replaces it, the same trade covered on custom checkboxes.

The value readouts above the sliders work through a scroll-driven animation. A view timeline set on the thumb is shared up to the row through timeline-scope, and an animation on the row moves --rs-v, which is registered as a <number> with @property. A counter prints it with counter-reset: rs-n round(var(--rs-v)). The readout runs in Chrome and Edge. Where it does not run, each row shows its range instead, like 0 to 100, and never a wrong number: --rs-v starts at -1, the keyframes are placed by range so a browser that runs them on plain time ignores them, and the number's opacity is clamp(0, v+1, 1) while the range label uses the inverse.

Accessibility notes

A range input is fully keyboard operable before any styling is applied. Arrow keys move by one step, Page Up and Page Down move by a larger amount, and Home and End jump to the ends. None of that survives a slider rebuilt from divs, which is the argument for restyling the native input even though it takes two sets of vendor rules to do.

outline: none on the input, as the base snippet has it, removes the only sign that a slider has focus. The arrows still work, but nobody can tell which control they are driving. This page adds the ring back with .rs-track:has(input:focus-visible) in the main demo, which puts it on the track box because the box clips the input's own ring, and with input[type="range"]:focus-visible in the variants, which puts it on the input with an offset so the ring clears the thumb. Copy one of those rules alongside the rest, or leave the native outline alone.

A slider's accessible value is a number, which is often meaningless on its own. A volume slider reading 40 gives no context about the scale, so add aria-valuetext when the number needs units or a word, or pair the input with a visible output. Also give it a real <label>, since a slider with no label exposes no accessible name.

The base thumb is a 22 pixel circle, the ring thumb 24 pixels, and the Rating thumb square. On a touch screen all of them are smaller than they look. Increasing the input's height with padding gives a taller grab area without changing the track thickness, since the track height is set independently in both engines.

Mistakes worth avoiding

  • Combining the WebKit and Firefox thumb selectors in one rule. Both engines discard the whole thing, so neither gets a styled thumb and the CSS looks like it was ignored.
  • Forgetting the second -webkit-appearance: none on the thumb. The track styles correctly and the handle stays as the browser drew it, which reads as a partial failure rather than a missing line.
  • Leaving border: none off ::-moz-range-thumb. Firefox draws its own border on the handle, so the same slider looks noticeably heavier there than in Chrome.
  • Setting outline: none and stopping there. The slider still responds to arrow keys but gives no indication it has focus, which makes it unusable without a mouse.
  • Assuming tick marks drawn as a gradient line up with the thumb. The thumb center travels the track width minus its own width, so an evenly spaced gradient drifts by half a thumb at each end unless the background is sized and offset to match.

Ship it

8 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
input type=range4233.112
appearance838015.483
box-shadow43.5512
repeating gradients103.65.112
:focus-visible86415.486
scroll-driven animations11515926115
@property8512816.485
round()12511815.4125

Figures come from Can I Use. The thumb and track pseudo-elements are not standard and are missing from the data set this table references: ::-webkit-slider-thumb works in Chrome, Edge, Safari and every other WebKit or Blink browser, ::-moz-range-thumb and ::-moz-range-progress work in Firefox, and neither engine recognizes the other's name. The overflow: hidden fill trick behaves in WebKit and Blink but not in Firefox, which is why the Firefox rule uses ::-moz-range-progress instead of a box shadow. Can I Use has no entry for scroll-driven animations, @property or round(); those rows come from MDN. The value readouts depend on a view timeline set on the slider thumb, which runs in Chrome and Edge. Firefox 159 and Safari 26 support scroll-driven animations but may not run a timeline on the thumb, and wherever it does not run, each slider shows its range, like 0 to 100, instead of the number.

What you can build with it

  • Price and value filters. A single slider for a maximum price, with the current value shown next to it.
  • Volume and brightness controls. Where the exact number matters less than the position, and dragging is the natural input.
  • Image editing adjustments. Brightness, contrast and saturation rows, all sharing the same styling and differing only in min and max.
  • Survey scales. A stepped slider with tick marks from 1 to 5, where the marks make it clear the answer is not continuous.
  • Settings with a sensible default. Set value to the default in the HTML so the slider starts in the right place with no script involved.

Frequently asked questions

Why is my range slider thumb still gray after styling it?
The thumb needs -webkit-appearance: none of its own, inside the ::-webkit-slider-thumb rule. Setting it on the input only clears the track. Until the thumb is reset it ignores width, height and background and keeps the handle the platform drew.
Can I style a range slider with one rule for all browsers?
No, a range slider cannot be styled with one rule for all browsers. A selector list containing an unknown pseudo-element is discarded whole, so ::-webkit-slider-thumb, ::-moz-range-thumb fails in both engines at once. The declarations have to be duplicated across two separate rules.
How do I fill the track behind the thumb without JavaScript?
In Firefox use ::-moz-range-progress, which is exactly that section of the track. In WebKit and Blink, give the thumb a box shadow with a large negative horizontal offset and a matching spread, then set overflow: hidden on the input so the shadow is clipped to the track. The first variant on this page shows both.
How do I show the current value of a slider?
In Chrome and Edge, CSS alone can do it. A scroll-driven animation reads where the thumb sits, a registered property holds that number, and a counter prints it, as in the demo above. Where that does not run, the demo shows each slider's range instead, and a live readout needs a line of script writing to an <output> element.
Does a range slider work on touch screens?
A range slider on touch screens drags as expected. The thumb is the only grabbable part though, so keep it at least 24 pixels and add vertical padding to the input to give a taller area to hit without making the track itself thicker.