track, thumb and fill level without a heavy JS library
A range input is one of the form controls with the biggest differences between browsers, because track, thumb and fill level each need their own vendor specific selectors. With the right CSS techniques, a fully custom slider can be built without loading an additional JavaScript library.
Table of Contents
- 1. Why range sliders are so hard to style consistently
- 2. Base reset: appearance none on input and thumb
- 3. Styling the track: height, radius, base color
- 4. Rendering the filled portion without a JS library
- 5. Tick marks and value labels with datalist
- 6. Thumb design: size, shadow, hover states
- 7. Styling vertical range sliders
- 8. Touch usability and keyboard focus
- 9. Native range slider vs. a JavaScript library
- 10. Summary
- 11. FAQ
1. Why range sliders are so hard to style consistently
A range slider visually consists of three parts that all need to be styled differently: the track as the background groove, the thumb as the movable handle, and the filled portion between the left edge and the thumb. No browser offers identical, prefix free selectors for all three parts, and default behavior differs in details like the vertical centering of the thumb or the existence of a separate filled area.
Firefox automatically renders the filled portion of a range slider with its own color via ::-moz-range-progress, while Chrome and Safari traditionally do not offer this area separately and instead require a trick using background and a linear-gradient. These fundamental differences are the main reason why a range input with a consistent fill level across all browsers remains one of the more demanding CSS tasks in the form area, even though accent-color, as described in another article of this series, is already enough for simple cases.
2. Base reset: appearance none on input and thumb
The first step for any individually styled range input is a complete reset of the native rendering, both on the input element itself and on both vendor specific thumb pseudo-elements. Without this reset, the native track graphic overlaps any custom background color, and the native thumb keeps its system typical shape, regardless of subsequent CSS rules.
A common beginner mistake: setting appearance: none only on the input element and forgetting the thumb pseudo-elements. The track then disappears, but the native thumb remains in its original shape, leading to a visually inconsistent result where a custom track meets a foreign, mismatched thumb. Both layers, input and thumb, need to be consistently reset.
/* Full reset, applies to the track element itself */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
background: transparent;
cursor: pointer;
}
/* Firefox draws its own focus outline around the whole element by default */
input[type="range"]::-moz-focus-outer {
border: 0;
}
3. Styling the track: height, radius, base color
After the reset, ::-webkit-slider-runnable-track in WebKit and Blink browsers as well as ::-moz-range-track in Firefox take over styling the background track. Both pseudo-elements accept height, radius and background color like an ordinary block element, but as with the thumb they need to be defined in separate selector lists, because an unrecognized selector otherwise invalidates the entire rule.
A detail that is often overlooked with range inputs: the track height in Firefox and WebKit reacts differently to the overall height of the input element. It is therefore recommended to consistently define the height on the track pseudo-element itself, instead of relying on the height of the surrounding input, which in practice leads to more reliable results across all browsers.
/* Chrome, Safari, Edge (Blink/WebKit) */
input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: #e5e7eb;
}
/* Firefox */
input[type="range"]::-moz-range-track {
height: 6px;
border-radius: 999px;
background: #e5e7eb;
}
4. Rendering the filled portion without a JS library
The filled portion left of the thumb, which visually shows the current progress, is the part of a range input with the most browser differences. Firefox offers a dedicated pseudo-element for it with ::-moz-range-progress, while Chrome and Safari offer no equivalent solution. The established trick for WebKit browsers is a linear-gradient background on the track itself, whose color stop sits exactly at the current percentage value of the slider.
For this color stop to move along with the slider's value, without loading a heavy JavaScript library, a single CSS custom property is enough, updated via the oninput attribute with a few lines of vanilla JavaScript. This approach stays fully CSS driven in terms of design, colors and transitions, and only needs a minimal percentage calculation in JavaScript, not an external slider library with its own rendering layer.
/* The fill percentage is stored in a CSS custom property, updated from JS */
input[type="range"] {
--range-progress: 50%;
}
/* WebKit/Blink: gradient background simulates a filled track */
input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(
to right,
#7c3aed 0%,
#7c3aed var(--range-progress),
#e5e7eb var(--range-progress),
#e5e7eb 100%
);
}
/* Firefox: native progress pseudo-element, no gradient trick needed */
input[type="range"]::-moz-range-progress {
height: 6px;
border-radius: 999px;
background: #7c3aed;
}
// Minimal vanilla JS: only computes the percentage, no slider library involved
document.querySelectorAll('input[type="range"]').forEach((slider) => {
const updateFill = () => {
const min = Number(slider.min || 0);
const max = Number(slider.max || 100);
const percent = ((Number(slider.value) - min) / (max - min)) * 100;
slider.style.setProperty('--range-progress', `${percent}%`);
};
slider.addEventListener('input', updateFill);
updateFill(); // set initial fill on page load
});
5. Tick marks and value labels with datalist
For range sliders with discrete steps, say a rating scale from one to five or size levels S, M, L, XL, the native datalist element combined with the list attribute of the range input is the right choice. The browser automatically draws small tick marks at the positions defined in datalist, without any custom JavaScript or extra DOM elements needed.
The visual styling of these tick marks is limited by browser, but Chrome now supports styling via ::-webkit-slider-container combined with generated content, and in most projects the default rendering of tick marks is entirely sufficient, combined with custom text labels below the slider, positioned via flexbox and independent of native tick rendering.
/* Labels positioned independently below the slider, aligned via flexbox */
.range-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.5rem;
}
<input type="range" min="1" max="5" step="1" list="rating-ticks">
<datalist id="rating-ticks">
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
<option value="5"></option>
</datalist>
<div class="range-labels">
<span>Poor</span>
<span>Neutral</span>
<span>Excellent</span>
</div>
6. Thumb design: size, shadow, hover states
The thumb of a range input benefits the most from individual styling, because it is the element the user actively grabs and moves. A sufficiently large click target, at least 20 by 20 pixels for desktop and 24 by 24 pixels for touch devices, clearly improves usability over the often too small native default thumb. A subtle shadow and a visible hover state additionally communicate that this is an interactive element.
What matters is a clearly visible active state while dragging, say a slight scale up or a stronger shadow, because otherwise users cannot reliably tell whether the thumb is actually being moved or the input is falling flat. This state can be defined via :active on the thumb pseudo-element itself and works independently of the fill trick described above.
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: #7c3aed;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
margin-top: -8px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
box-shadow: 0 3px 8px rgba(124, 58, 237, 0.55);
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.15);
}
input[type="range"]::-moz-range-thumb {
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: #7c3aed;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
7. Styling vertical range sliders
A vertical range input, say for a volume control, can be implemented in two ways. Firefox supports the native orient="vertical" attribute directly on the input, while Chromium browsers instead need either writing-mode: vertical-lr combined with direction: rtl, or the newer property appearance: slider-vertical, which was proprietary in older Chrome versions and is now more broadly supported.
In practice, the combination of writing-mode and a fixed width plus height is the most reliable cross-browser solution, because it relies on standard properties instead of a browser specific orient attribute. The fill trick from section four needs to be adapted for the vertical variant, because the gradient then has to run from bottom to top instead of left to right.
/* Cross-browser vertical range input via writing-mode */
.range-vertical {
writing-mode: vertical-lr;
direction: rtl;
appearance: slider-vertical; /* Chromium fallback where supported */
width: 6px;
height: 160px;
}
8. Touch usability and keyboard focus
An often underestimated aspect of styling a range input is the minimum click target size for touch devices. WCAG guideline 2.5.5 recommends at least 24 by 24 CSS pixels for interactive targets, and a thumb styled too small significantly frustrates users on touchscreens, because the finger regularly misses the narrow native thumb. A generous but visually subtle click area, larger than the visible thumb icon, improves hit accuracy without affecting the design.
For keyboard users, the native range input remains fully usable with arrow keys, page up, page down, home and end, as long as appearance: none does not impair this functionality, which it does not, because appearance only affects appearance. A custom focus ring with :focus-visible is still mandatory, because appearance: none removes the system focus ring here too, and without a replacement, keyboard usability remains visually unclear.
9. Native range slider vs. a JavaScript library
Whether a native, CSS styled range input is enough or an external JavaScript library becomes necessary primarily depends on whether a single value selection or a range selection with two handles is needed. The following table compares both approaches by the most important criteria.
| Criterion | Native range input with CSS | JS library (e.g. noUiSlider) |
|---|---|---|
| Bundle size | 0 KB additional | Typically 20 to 40 KB |
| Keyboard control | Native, guaranteed correct | Must be implemented by the library |
| Dual thumb (range selection) | Not natively possible | Standard feature |
| Mobile value display while dragging | Not native, must be built manually | Usually built in |
| Maintenance effort | Low, pure CSS/HTML | Requires dependency updates |
For a single value, the native range input with CSS styling is almost always the better choice, because it adds no extra weight to the page and comes with guaranteed correct keyboard control. As soon as a range selection with two handles is needed, say for a price filter from minimum to maximum, native range inputs hit their limit, because a single input type range only knows one value, and a JS library or two overlaid range inputs with additional logic become necessary.
Mironsoft
Form UI, sliders and lightweight frontend solutions
Range sliders without unnecessary JavaScript weight?
We build individually styled range inputs with pure CSS, including fill level display, tick marks and a touch optimized thumb, entirely without a heavy slider library.
Slider design
Style track, thumb and fill level to match the brand
Performance audit
Replace unnecessary JS libraries with native solutions
Touch optimization
Adapt click targets and focus states to WCAG
10. Summary
An individually styled range input requires separate CSS rules for track, thumb and filled portion, each with webkit and moz prefixes. The fill level can be rendered without a JS library by having a CSS custom property store the current percentage value, updated via a minimal vanilla JavaScript line on every input event. Tick marks come natively via datalist, vertical sliders via writing-mode.
For the majority of use cases, a single value on a scale, the native range input with CSS styling is clearly preferable to a heavyweight JS library, because it adds no extra bundle weight and guarantees correct keyboard control as well as touch usability. Only a genuine dual thumb range selection makes an external solution necessary.
Styling range inputs — the essentials at a glance
Three parts
Style track, thumb and fill level separately, each with webkit and moz prefixes.
Fill without JS lib
A CSS custom property plus a minimal vanilla JS line replaces complete slider libraries.
Tick marks
Native datalist element with the list attribute, no JavaScript needed.
Touch and keyboard
At least 24 by 24 pixel click target, custom focus ring after appearance: none.