Customising Cross-Browser Range Inputs with CSS and JavaScript

Avatar of Steven Estrella
Steven Estrella on (Updated on )

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

The following is a guest post from 2015 by Steven Estrella that he just updated in January 2019. Steven shared with me a technique for creating customized range inputs by writing a little JavaScript atop some of the techniques explored here by Daniel Stern and others. I invited Steven to explain his approach.

Fine wine and good friendships age well. Code is another matter. Things change and sometimes they get easier. The old CodePen from 2015 still works but it uses a level of complexity that is no longer needed to accomplish the task at hand. So, I created a new CodePen to illustrate an updated approach and asked the good folks at CSS-Tricks if I could update this article.

In 2014, Daniel Stern wrote a very useful article to demonstrate cross-browser styling of the HTML5 range input using nothing more than CSS and HTML. He even created a handy tool called range.css for generating the CSS. Sometimes, however, our designs may need to go beyond what is possible with CSS alone.

In the example below I wanted to experiment with what’s currently possible when manipulating a range input with JavaScript:

See the Pen
Custom Range Input with CSS and JavaScript 2019
by Steven Estrella (@sgestrella)
on CodePen.

Notice how the track fills with a gradient as you drag the thumb? Also, the thumb uses an image as its background, rotates whilst you drag, and displays the value as text. And of course there is the not-so-small-matter of allowing for both horizontal or vertical slider orientations and preserving inputs from the keyboard too. We’ll go into further detail on that soon. Ultimately, this tutorial will walk you through the code and concepts to add this type of control over the appearance and functionality of HTML5 range inputs.

Marking things up

Our approach is to make the standard range input invisible to the user. It will still function normally and will be just as accessible but its appearance will be replaced by our own styled divs. Then we will wire them all together with about 50 lines of JavaScript. When the user drags the invisible range input thumb, it will fire event listeners that call a function and transmit the range input value. Consequently, this will trigger changes to the appearance of the styled divs.

OK, so let’s start with the markup for a single range input:

<div class="rangewrapper horizontal">
  <div class="sliderfill">
  <input class="customrange" type="range" min="0" max="100" value="50">
  </div>
  <div class="sliderthumb"></div>
  <div class="slidervalue">50</div>
</div>
  • The outer .rangewrapper div exists to provide a positioning context for the divs it contains. The second listed class can be either horizontal or vertical.
  • .sliderfill will be set to a gradient background that changes the appearance as the user drags the thumb.
  • <input type="range"> is the actual range input element which we will set to a very low opacity.
  • .sliderthumb makes the 90’s-style beveled square image that looks like marble.
  • .slidervalue styles the current value of the slider input.
  • .sliderthumb and .slidervalue will be absolutely positioned and set to ignore pointer events.

In our example we’ll create four of these .rangewrapper elements with the last one being set to vertical orientation. I grouped the three horizontal sliders together within a <div class="rangepresenter"> which provides structure and a place for a heading. I placed the single vertical slider into <div class="rangepresenter verticalsliders">. Both of those divs exist within <article class="content"> which is set to display as a grid with 1 column on narrow screens and 2 columns on larger screens using media queries. You can see the complete CSS in the code pen but here are the highlights.

/* Modified Meyer Reset to smooth out browser differences*/
/* CSS variables to keep appearance consistent */
:root {
    /*variables for font color and other cosmetics*/
    /*variables for gradient color stops and sizing the sliders*/
    --accentcolor: rgb(0,128,128);
    --accentcoloralpha: rgba(0,128,128,0.5);
    --maxwidth: 800px;
    --lineheight: 1.3;
    --thumbsize: 40px;
    --tracksize: 300px;
    --trackheight: 28px;
    --trackradius: 6px;
    --innertrackradius: 4px;
}
/*Grid layout with media queries follows*/
/*Then cosmetic styles for the wrapper, headings, content, etc... */
/*Here is the heart of the matter.*/
.rangepresenter {
  width:var(--tracksize);
  height:auto;
}
/*The width is adjusted for vertical sliders and the height is specified.*/
/*Relative position is important to provide a positioning context for the 
vertical slider which will be rotated.*/
.rangepresenter.verticalsliders {
    max-width:calc(var(--tracksize)/2);
    min-height:calc(var(--tracksize) + 60px);
    position:relative;
 }
/*Relative position for rangewrapper is important to provide a positioning context 
for the thumb and value text. It also provides a background color for the slider track.*/
.rangewrapper {
  line-height:var(--lineheight);
  border:2px solid var(--maincolor);
  border-radius:var(--trackradius);
  margin:20px 0 40px 0;
  padding:0;
  position:relative;
  width:var(--tracksize);
  height:var(--trackheight);
  overflow:visible;
  background-color:#ffc;
}
/*The rangewrapper class is rotated when vertical. The position must then be 
calculated so it appears centered after the transformation.*/
.rangewrapper.vertical{
  transform-origin: 50% 50%;
  transform: rotate(-90deg);
  position:absolute;
  top:calc( (var(--tracksize)/2) + 30px);
  left:-50%;
  margin:0;
}
/*The border-radius for the inner fill of the slider track is just a bit smaller 
than the border-radius for the enclosing rangewrapper div.*/
.sliderfill {
  border:0 none;
  border-radius:var(--innertrackradius);
  margin:0;
  padding:0;
  height:100%;
}
/*The sliderthumb uses an image and it is absolutely position using a calculation. 
Pointer-events are set to none so it won't block dragging the range input thumb. 
We will use javascript to be sure the sliderthumb always appears at the same location 
as the real range input thumb.*/
.sliderthumb {
  width:var(--thumbsize);
  height:var(--thumbsize);
  background-image:url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/358203/thumb.png');
  background-size: 100% 100%;
  background-repeat: no-repeat;
  background-color:transparent;
  position:absolute;
  left:0;
  top:calc(((var(--thumbsize) - var(--trackheight))/-2) - 2px);
  border:0 none;
  padding:0;
  pointer-events:none;
}
/*The slidervalue displays the current value of the slider at all times and is 
positioned absolutely just like the sliderthumb.*/
.slidervalue {
  width:var(--thumbsize);
  height:var(--thumbsize);
  line-height:var(--thumbsize);
  position:absolute;
  left:calc(50% - (var(--thumbsize)/2));
  top:calc(((var(--thumbsize) - var(--trackheight))/-2) - 2px);
  color:white;
  font-family:var(--mainfont);
  font-size:1.1rem;
  font-weight:normal;
  border:0 none;
  pointer-events:none;
}
/*When the slider is vertical we need to compensate by rotating the text 90 degrees.*/
.vertical .slidervalue {
  transform:rotate(90deg);
}
/*The customrange class sets each range input to a low opacity.*/
.customrange {
  cursor:pointer;
  height:100%;
  width:var(--tracksize);
  opacity:0.05;
}

The JavaScript

Finally, we can tie everything together with JavaScript. Variables are created to hold the object references for the sliders, thumbs, fills, and values. Initial values for the sliders are placed in an array. Once the document loads, the init function is called and the sliders are updated to reflect the initialValue array. Event listeners are added to respond to both input and change events on the range inputs. They trigger the updateSlider function which receives a parameter representing the slider number and a second parameter representing the value chosen in the range element. Then, with some simple math, we can use the value to position and rotate the thumb, display the value as text, and fill the track with a gradient as the user drags the thumb.

let sliders, sliderfills, thumbs, slidervalues;
let initialValue = [38,50,63,88]; //initial values for the sliders

document.addEventListener('DOMContentLoaded', function (e) { init();});

function init(){
  sliders = document.querySelectorAll(".customrange");
  sliderfills = document.querySelectorAll(".sliderfill");
  thumbs = document.querySelectorAll(".sliderthumb");
  slidervalues = document.querySelectorAll(".slidervalue");
  /* We need to change slider appearance to respond to both input and change events. */
  for (let i=0;i<sliders.length;i++){
    sliders[i].addEventListener("input",function(e){
      updateSlider(i,sliders[i].value);});
    sliders[i].addEventListener("change",function(e){
      updateSlider(i,sliders[i].value);});
    //set initial values for the sliders
    sliders[i].value = initialValue[i];
    //update each slider
    updateSlider(i,sliders[i].value);
  }
}
function updateSlider(fillindex,val){
  //sets the text display and location for each thumb and the slider fill  
  setThumbText(slidervalues[fillindex],val);
  setThumb(thumbs[fillindex],val);
  setSliderFill(sliderfills[fillindex],val);
}
function setThumbText(elem,val){
  let size = getComputedStyle(elem).getPropertyValue("--thumbsize");
  let newx = `calc(${val}% - ${parseInt(size)/2}px)`;
  elem.style.left = newx;
  elem.innerHTML = val;
}
function setThumb(elem,val){
  let size = getComputedStyle(elem).getPropertyValue("--thumbsize");
  let newx = `calc(${val}% - ${parseInt(size)/2}px)`;
  elem.style.left = newx;
  let degrees = 360 * (val/100);
  let rotation = `rotate(${degrees}deg)`;
  console.log(rotation);
	elem.style.transform = rotation;
}
function setSliderFill(elem,val){
  let fillcolor = getComputedStyle(elem).getPropertyValue("--accentcolor");
  let alphafillcolor = getComputedStyle(elem).getPropertyValue("--accentcoloralpha");
  // we create a linear gradient with a color stop based on the slider value
  let gradient = `linear-gradient(to right, ${fillcolor} 0%, 
${alphafillcolor} ${val}%, 
rgba(255,255,255,0.1) ${Number(val) + 1}%, 
rgba(255,255,255,0)  100%)`;
  elem.style.backgroundImage = gradient;
}

Wrapping up

For the longest time styling inputs was beyond challenging. But now, with a little CSS and JavaScript, we can fix these problems in all modern browsers without the heroic efforts required in the past.

More information