SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/range-sliders

8 CSS Range Sliders

1 Pure CSS7 with JSPublished 2026-10-04Free · no sign-up

Eight range slider designs built on the native <input type="range">, which brings keyboard arrows, touch dragging and screen reader support for free. They include a filled track, a two-handle price filter, a labelled five-step scale, vertical mixer faders and a slider paired with a number box for exact values.

01. Styled Range Slider

FormRange

A native range input restyled for both WebKit and Firefox. The filled part of the track is a gradient whose stop follows the value through a custom property set by one line of JavaScript, and the thumb keeps a visible focus ring.

View code
<label class="rs1">
  <span>Volume <output id="rs1o">60</output></span>
  <input type="range" min="0" max="100" value="60" id="rs1">
</label>
.rs1 { display: grid; gap: 12px; width: min(320px, 86vw); color: #c9d1e2; font: 600 14px Inter, system-ui, sans-serif; }
.rs1 span { display: flex; justify-content: space-between; }
.rs1 output { color: #fff; }
.rs1 input { --v: 60%; appearance: none; height: 8px; margin: 0; border-radius: 999px; background: linear-gradient(90deg, #6366f1 var(--v), #2a3142 var(--v)); cursor: pointer; }
.rs1 input::-webkit-slider-thumb { appearance: none; width: 22px; height: 22px; border-radius: 50%; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.4); }
.rs1 input::-moz-range-thumb { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #fff; }
.rs1 input:focus-visible { outline: none; }
.rs1 input:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px rgba(99,102,241,.5); }
.rs1 input:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 4px rgba(99,102,241,.5); }
const r = document.getElementById('rs1'), o = document.getElementById('rs1o');
function paint() { r.style.setProperty('--v', r.value + '%'); o.textContent = r.value; }
r.addEventListener('input', paint);
paint();

02. Dual Thumb Price Range

FilterE-commerce

A minimum and maximum price filter made from two overlapping range inputs. Only the thumbs receive pointer events, a script stops the handles crossing, and both values are shown in text that updates as you drag.

View code
<fieldset class="dual">
  <legend>Price: <b id="dl">$40</b> to <b id="dh">$160</b></legend>
  <div class="track">
    <input type="range" id="lo" min="0" max="200" value="40" aria-label="Minimum price">
    <input type="range" id="hi" min="0" max="200" value="160" aria-label="Maximum price">
  </div>
</fieldset>
.dual { width: min(340px, 88vw); margin: 0; padding: 0; border: 0; color: #344054; font: 500 14px Inter, system-ui, sans-serif; }
.dual legend { margin-bottom: 18px; }
.dual b { color: #101828; }
.track { --lo: 20%; --hi: 80%; position: relative; height: 6px; border-radius: 999px; background: linear-gradient(90deg, #d0d5dd var(--lo), #0f766e var(--lo) var(--hi), #d0d5dd var(--hi)); }
.track input { position: absolute; inset: -8px 0 0; width: 100%; height: 22px; margin: 0; appearance: none; background: none; pointer-events: none; }
.track input::-webkit-slider-thumb { appearance: none; width: 22px; height: 22px; border: 3px solid #0f766e; border-radius: 50%; background: #fff; pointer-events: auto; cursor: grab; }
.track input::-moz-range-thumb { width: 18px; height: 18px; border: 3px solid #0f766e; border-radius: 50%; background: #fff; pointer-events: auto; cursor: grab; }
.track input:focus-visible { outline: none; }
.track input:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px rgba(15,118,110,.25); }
const lo = document.getElementById('lo'), hi = document.getElementById('hi'), tr = document.querySelector('.track');
function upd(e) {
  if (+lo.value > +hi.value - 10) { if (e && e.target === lo) lo.value = +hi.value - 10; else hi.value = +lo.value + 10; }
  tr.style.setProperty('--lo', lo.value / 2 + '%');
  tr.style.setProperty('--hi', hi.value / 2 + '%');
  document.getElementById('dl').textContent = '$' + lo.value;
  document.getElementById('dh').textContent = '$' + hi.value;
}
lo.addEventListener('input', upd); hi.addEventListener('input', upd);
upd();

03. Stepped Slider with Labels

RangeScale

A slider that snaps to five named steps, from Very unlikely to Very likely, with the labels printed under the track. A datalist supplies the tick marks, and aria-valuetext makes screen readers announce the words instead of the numbers 1 to 5.

View code
<div class="stp">
  <label for="lk">How likely are you to recommend us?</label>
  <input id="lk" type="range" min="1" max="5" step="1" value="4" list="lk-t" aria-valuetext="Likely">
  <datalist id="lk-t"><option value="1"></option><option value="2"></option><option value="3"></option><option value="4"></option><option value="5"></option></datalist>
  <div class="lbls" aria-hidden="true"><span>Very unlikely</span><span>Unlikely</span><span>Not sure</span><span>Likely</span><span>Very likely</span></div>
</div>
.stp { display: grid; gap: 12px; width: min(420px, 90vw); color: #e7ebf5; font: 600 15px Inter, system-ui, sans-serif; }
.stp input { width: 100%; margin: 0; accent-color: #34d399; }
.lbls { display: grid; grid-template-columns: repeat(5, 1fr); color: #9aa4b8; font-size: 11.5px; font-weight: 500; text-align: center; }
.lbls span:first-child { text-align: left; }
.lbls span:last-child { text-align: right; }
const words = ['Very unlikely', 'Unlikely', 'Not sure', 'Likely', 'Very likely'];
const lk = document.getElementById('lk');
lk.addEventListener('input', () => lk.setAttribute('aria-valuetext', words[lk.value - 1]));

04. Vertical Mixer Faders

RangeAudio

Three vertical faders like an audio mixer. They use writing-mode: vertical-lr with direction: rtl so the value increases upwards, which keeps the native keyboard and screen reader support of a range input instead of rotating it with a transform.

View code
<div class="mix">
  <label><input type="range" min="0" max="100" value="70"><span>Vocals</span></label>
  <label><input type="range" min="0" max="100" value="45"><span>Guitar</span></label>
  <label><input type="range" min="0" max="100" value="85"><span>Drums</span></label>
</div>
.mix { display: flex; gap: 34px; padding: 22px 30px; border-radius: 16px; background: #151a26; font: 600 12px Inter, system-ui, sans-serif; color: #aab3c7; }
.mix label { display: grid; justify-items: center; gap: 12px; }
.mix input { writing-mode: vertical-lr; direction: rtl; width: 10px; height: 180px; margin: 0; accent-color: #f472b6; }

05. Gradient Track Temperature

RangeColour

A temperature control with a cold-to-hot gradient track and a white thumb that shows the current value in a bubble above it. The bubble is positioned with a custom property from the value and also printed as text for screen readers.

View code
<div class="temp">
  <label for="tmp">Thermostat</label>
  <div class="wrap">
    <output id="tmp-o" for="tmp">21&deg;C</output>
    <input id="tmp" type="range" min="16" max="28" value="21">
  </div>
</div>
.temp { width: min(340px, 88vw); color: #344054; font: 600 14px Inter, system-ui, sans-serif; }
.wrap { --p: 41.7%; position: relative; padding-top: 38px; }
.wrap output { position: absolute; top: 0; left: var(--p); transform: translateX(-50%); padding: 4px 8px; border-radius: 8px; background: #101828; color: #fff; font-size: 13px; }
.temp input { appearance: none; width: 100%; height: 10px; margin: 0; border-radius: 999px; background: linear-gradient(90deg, #38bdf8, #a3e635, #f59e0b, #ef4444); cursor: pointer; }
.temp input::-webkit-slider-thumb { appearance: none; width: 24px; height: 24px; border: 4px solid #fff; border-radius: 50%; background: #101828; box-shadow: 0 2px 6px rgba(0,0,0,.3); }
.temp input::-moz-range-thumb { width: 18px; height: 18px; border: 4px solid #fff; border-radius: 50%; background: #101828; }
.temp input:focus-visible { outline: 2px solid #101828; outline-offset: 6px; }
const t = document.getElementById('tmp'), out = document.getElementById('tmp-o');
function move() {
  const p = (t.value - t.min) / (t.max - t.min);
  t.parentElement.style.setProperty('--p', 'calc(' + (p * 100) + '% + ' + (12 - p * 24) + 'px)');
  out.textContent = t.value + '°C';
}
t.addEventListener('input', move);
move();

06. Slider with Tick Scale

RangeScale

A zoom control with tick marks every 25 percent drawn by a repeating linear gradient under the track, plus minus and plus buttons for people who find dragging difficult. The buttons nudge the slider by one step and fire an input event.

View code
<div class="zoom">
  <button type="button" aria-label="Zoom out" data-d="-25">&minus;</button>
  <div class="zt"><input type="range" min="25" max="200" step="25" value="100" aria-label="Zoom" id="zm"><span class="ticks" aria-hidden="true"></span></div>
  <button type="button" aria-label="Zoom in" data-d="25">+</button>
  <output for="zm" id="zo">100%</output>
</div>
.zoom { box-sizing: border-box; max-width: 100%; display: flex; align-items: center; gap: 10px; color: #101828; font: 700 14px Inter, system-ui, sans-serif; }
.zoom button { width: 34px; height: 34px; border: 1.5px solid #d0d5dd; border-radius: 10px; background: #fff; font-size: 18px; cursor: pointer; }
.zoom button:focus-visible { outline: 2px solid #2563eb; }
.zt { position: relative; width: 220px; }
.zt input { position: relative; z-index: 1; width: 100%; margin: 0; accent-color: #2563eb; }
.ticks { position: absolute; left: 8px; right: 8px; top: 22px; height: 6px; background: repeating-linear-gradient(90deg, #98a2b3 0 1.5px, transparent 1.5px calc(100% / 7)); }
.zoom output { min-width: 46px; font-variant-numeric: tabular-nums; }
const zm = document.getElementById('zm'), zo = document.getElementById('zo');
zm.addEventListener('input', () => zo.textContent = zm.value + '%');
document.querySelectorAll('.zoom button').forEach(b => b.addEventListener('click', () => {
  zm.value = +zm.value + +b.dataset.d;
  zm.dispatchEvent(new Event('input'));
}));

07. Emoji Feedback Slider

FeedbackRange

A playful satisfaction slider where a face above the track changes from sad to delighted as the value rises. The face is decorative, while aria-valuetext gives a word for each step, so the meaning does not depend on the picture.

View code
<div class="emo">
  <p class="face" aria-hidden="true">&#128578;</p>
  <label for="emo-r">How was your delivery?</label>
  <input id="emo-r" type="range" min="1" max="5" value="4" aria-valuetext="Good">
</div>
.emo { display: grid; justify-items: center; gap: 10px; width: min(320px, 86vw); color: #e7ebf5; font: 600 15px Inter, system-ui, sans-serif; }
.face { margin: 0; font-size: 54px; line-height: 1; }
.emo input { width: 100%; margin: 0; accent-color: #facc15; }
const faces = ['\u{1F61E}', '\u{1F615}', '\u{1F610}', '\u{1F642}', '\u{1F604}'];
const names = ['Very poor', 'Poor', 'Okay', 'Good', 'Excellent'];
const er = document.getElementById('emo-r');
er.addEventListener('input', () => {
  document.querySelector('.face').textContent = faces[er.value - 1];
  er.setAttribute('aria-valuetext', names[er.value - 1]);
});

08. Range with Number Input Pair

FormPrecision

A slider paired with a number box so people can either drag roughly or type an exact value. Both controls stay in sync in either direction, share one visible label through aria-labelledby, and the number input enforces the same minimum and maximum.

View code
<div class="pair">
  <p id="pr-l">Discount (%)</p>
  <div class="ctl">
    <input type="range" id="pr-r" min="0" max="50" value="15" aria-labelledby="pr-l">
    <input type="number" id="pr-n" min="0" max="50" value="15" aria-labelledby="pr-l">
  </div>
</div>
.pair { width: min(360px, 90vw); color: #344054; font: 600 14px Inter, system-ui, sans-serif; }
.pair p { margin: 0 0 10px; }
.ctl { display: flex; align-items: center; gap: 14px; }
.ctl input[type=range] { flex: 1; margin: 0; accent-color: #db2777; }
.ctl input[type=number] { width: 72px; padding: 8px 10px; border: 1.5px solid #d0d5dd; border-radius: 10px; font: 600 15px Inter, sans-serif; color: #101828; outline: none; }
.ctl input[type=number]:focus { border-color: #db2777; box-shadow: 0 0 0 3px rgba(219,39,119,.15); }
const pr = document.getElementById('pr-r'), pn = document.getElementById('pr-n');
pr.addEventListener('input', () => pn.value = pr.value);
pn.addEventListener('input', () => { const v = Math.max(0, Math.min(50, +pn.value || 0)); pr.value = v; });
pn.addEventListener('change', () => pn.value = pr.value);

About these CSS Range Sliders

Styling across browsers

Range inputs are styled through vendor pseudo-elements: ::-webkit-slider-thumb for Chrome, Edge and Safari, and ::-moz-range-thumb for Firefox. Set appearance: none on the input and on the WebKit thumb, then style both thumbs the same way.

Filling the track

There is no standard pseudo-element for the filled part of the track. These demos paint it with a linear gradient whose colour stop follows a custom property, updated from the input event with one line of JavaScript.

Words instead of numbers

If each step means something, such as Very unlikely to Very likely, set aria-valuetext so screen readers announce the words. Show the current value in visible text too, ideally in an <output> element linked to the slider.

Precision and motor access

Dragging a small thumb is hard for many people. Offer plus and minus buttons or a number input next to the slider, as the zoom and number pair demos do, and make the thumb at least 20 pixels across.

Frequently asked questions

How do I make a vertical slider?

Use writing-mode: vertical-lr with direction: rtl, as in the mixer demo, rather than rotating the input with a transform.

Can one input have two handles?

No. The price range overlays two range inputs and stops them crossing with a small script.

Do these work on touch screens?

Yes. Native range inputs support touch dragging in every mobile browser.