CSS Range Slider

A custom-styled range input using CSS track and thumb pseudo-elements.

Published May 19, 2026 Intermediate 8 min read

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 unrecognised 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.

Styled track and thumb

HTML

<div class="range-group">
  <input type="range" min="0" max="100" value="40">
  <input type="range" min="0" max="100" value="70">
</div>

CSS

.range-group {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  align-items: center;
}

/* 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;
}

Other ways to build it

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;
}

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.

CSS properties used

appearance
none on the input clears the native track, and a second none on ::-webkit-slider-thumb clears the handle. Both are needed in WebKit.
::-webkit-slider-thumb
The draggable handle in Chrome, Edge and Safari. Needs its own rule, separate from the Firefox one.
::-moz-range-thumb
The same handle in Firefox. Also needs border: none, since Firefox draws one by default.
::-moz-range-progress
The filled section from the track start to the thumb. Firefox only, with no WebKit counterpart.
box-shadow
A large negative offset with a matching spread paints the filled part of the track in WebKit, clipped by overflow: hidden on the input.
background-image
A repeating gradient draws tick marks along the track, sized and offset so the marks line up with where the thumb center can actually stop.

Browser support

FeatureChromeFirefoxSafariEdge
input type=range4233.112
appearance838015.483
box-shadow43.5512
repeating gradients103.65.112
:focus-visible86415.486

Figures come from Can I Use. The thumb and track pseudo-elements have no Can I Use entry because they are not standard: ::-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 recognises 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.

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 input[type="range"]:focus-visible with an accent outline and a four pixel offset so the ring clears the thumb. Copy that rule alongside the rest, or leave the native outline alone.

A slider announces its value as a number, which is often meaningless on its own. A volume slider reading 40 tells a screen reader user nothing 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 is announced as an unnamed slider.

The thumb is a 22 pixel circle, which clears the 24 pixel target minimum only just, and on a touch screen it is smaller than it looks. 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.

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.

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.

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 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?
Not with CSS alone. The value is not exposed to the stylesheet, so a live readout needs a line of script writing to an <output> element. Tick marks and labels under the track are the pure CSS answer, and for many sliders they are enough.
Does a range slider work on touch screens?
Yes, and dragging works 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.