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
Track that fills in behind the thumb
See the codeStepped slider with tick marks
See the codeBuild 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: noneon 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: noneoff::-moz-range-thumb. Firefox draws its own border on the handle, so the same slider looks noticeably heavier there than in Chrome. - Setting
outline: noneand 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 | ||||
|---|---|---|---|---|
input type=range | 4 | 23 | 3.1 | 12 |
appearance | 83 | 80 | 15.4 | 83 |
box-shadow | 4 | 3.5 | 5 | 12 |
repeating gradients | 10 | 3.6 | 5.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
scroll-driven animations | 115 | 159 | 26 | 115 |
@property | 85 | 128 | 16.4 | 85 |
round() | 125 | 118 | 15.4 | 125 |
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
minandmax. - 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
valueto 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?
-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?
::-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?
::-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?
<output> element.