Skip to main content

Creating an Accessible Range Slider with CSS

Note: this blog is an archive and not actively maintained. Some information may be out of date. If you'd like to see what I am working on or work with me in a consulting capacity, visit my website lindseykopacz.com.

I’ve always loved exploring how to make semantic, accessible elements aesthetically pleasing. And yes, it is possible. Challenging myself to do this has improved my CSS skills.

Today we are going to talk about input[type="range"]. These inputs where you select a value from a slider that has a thumb on it. You start with HTML that looks like this:

<div class="wrapper">
  <input type="range" min="0" max="100" id="volume" />
  <label class="visually-hidden" for="volume">Volume</label>
</div>

NOTE: I use the visually hidden class to ensure that we have a label for screen readers.

Selecting a purpose for an input range slider has many possibilities. You could use it for volume decibel. You could use it for an audio player. You could use it in a survey when rating from 1-10. Many times, when people make these features custom, they don’t think to use the semantic features. They make a bunch of divs, and don’t think about the keyboard or screen reader accessibility.

Considerations

Today I am going to be playing around with CSS to show you some of the possibilities. A lot of what I do in this post is a form of exploration. I learned a lot about linear-gradient from research for this post, and we’ll be going over how I perceived it.

When I go through these steps, I am considering the following:

  1. Browser support - I don’t talk about browser support often. But according to the POUR principles, your site and application must be robust. In the post, we’ll be going over many pseudo-elements for robust browser support.
  2. Providing a fallback - linear-gradient isn’t always supported. It looks neat, but we have to ensure we have a clear way of presenting the range even if it’s not possible
  3. Don’t use JavaScript to hack - just because you can doesn’t mean you should.

Starting out

When we first create that HTML, the appearance is pretty unimpressive.

An input range slider unstyled.

The first thing we want to do is start with a clean slate in our CSS. We are going to add a position: relative; on our wrapper class. Then we add an appearance: none; to our range input. If you check out the Mozilla docs, prepending -webkit- should cover us for Firefox, Chrome, and edge.

.wrapper {
  position: relative;
  margin: 1rem;
}

input[type='range'] {
  -webkit-appearance: none;
}

The slider with no track, only the thumb.

There are a few things that I am going to “normalize” before I start going into the pseudo-elements. I am adding a width and a transparent background. For this particular item, I am going to add a max-width as well.

input[type='range'] {
  -webkit-appearance: none;
  max-width: 7rem;  width: 100%;  background: transparent;}

Browser Specific Pseudo Elements

You’d think that you could just add a bunch of properties like height, background color, and margin, like this:

input[type='range'] {
  -moz-appearance: none;
  -webkit-appearance: none;
  max-width: 7rem;
  width: 100%;
  margin: 0;  height: 0.9rem;  background: #464646;}

However, if you use something like BrowserStack, you’ll see something like this in IE11.

An input range that has a blue fill for the lower track on the left side of the thumb and white fill for the upper track on the right side of the thumb. There is a dark grey background behind the slider.

Depending on what your analytics say for your audience’s browser usage, this may be moot. And take that knowledge and apply it how you please. But I believe in robust browser support, so I’m going to cover the following pseudo-elements:

  • ::-webkit-slider-runnable-track
  • ::-moz-range-track
  • ::-ms-track

Note: if you want to use these pseudo-elements, you have to create the selectors separately. That’s because if a browser isn’t supported, it’ll ignore the entire block.

input[type='range']::-webkit-slider-runnable-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  background: #464646;
}

input[type='range']::-moz-range-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  background: #464646;
}

input[type='range']::-ms-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  color: transparent;
  background: #464646;
}

input[type='range']::-ms-fill-lower {
  background: transparent;
}

In Chrome:

Unstyled, round, light grey thumb, with a dark grey rectangular track.

In Firefox:

Unstyled, round, light grey thumb, with a dark grey rectangular track.

In Edge:

Unstyled, square, black thumb, with a dark grey rectangular track.

In IE11:

Unstyled, square, black thumb, with a dark grey rectangular track.

The main difference between the properties inside the blocks is the ::-ms-track pseudo-element needs a color: transparent. We also need to have a ::-ms-fill-lower otherwise it looks like this:

In IE11, unstyled, square, black thumb, with a rectangular track. The lower left track is a light blue and the upper right track is dark grey.

When we add all these in, we have a starting out point for our volume slider:

Now we get to play around with the part I explored the most - linear gradient.

Using linear-gradient

Linear gradient confused me for the longest time. Because I focus on accessibility more than CSS, I never learned all the fun CSS functions. I want a volume bar that shows a triangle going from the lowest step to the highest step while giving a screen reader user the ability to control volume decibel.

Our goal would be to have something that looks similar to this:

A dark grey right triangle, the right angle being the bottom right. There is a tall yet narrow rounded corner thumb.

linear-gradient is a CSS function that transforms a background from one or many colors into another. This function takes on many arguments and can be very simple or very complex.

From the most basic standpoint, here is what to consider when you’re creating a linear gradient:

  • The angles and the direction of the gradient
  • What colors you want in the gradient.
  • What percent points you want the “fade” to start and stop. More formally, we call these color stops.

I have a few examples that I played with that I show in the following CodePens.

First a rainbow:

A few of my observations:

  • If there’s no direction or angle specified, it assumes a horizontal (0deg) angle and top to bottom direction.
  • If you don’t specify where the colors stop, the gradient assumes even distribution.

Now a French Flag:

My first observation: I associate linear gradients as fades. But if you want a straight line, you have to put the “stop” to be at equal values.

.flag--fr {
  background: linear-gradient(
    /* The gradient is going toward the right */
    to right,
    /* It starts at blue and stops in full opacity at 33% */
    blue 33%,
    /* White starts in full opacity at 33% */
    white 33%,
    /* White ends in full opacity at 66% */
    white 66%,
    /* Red starts in full opacity at 66%
    and will continue till the background is over */
    red 66%
  )
}

What if we want to start playing with fades and make it more blurry?

There is a gap between the stops at full opacity, and in between that gap, there is a fade. Fun stuff!

.flag--fr {
  background: linear-gradient(
    to right,
    /* Blue stops at 27%, starts to
    fade into the next color */
    blue 27%,
    /* White starts at 33% */
    white 33%,
    /* White stops at 66%, starts to
    fade into next color */
    white 66%,
    /* Red starts at 73% */
    red 73%)
}
french gradient annotated

I’m still a complete newb to this stuff, so I bet I am missing many details. But this is my “explain like I’m five” version of this.

So, this feels like a tangent, but I promise it’s not. We are going to use what we learned to create a volume range. We want to visually indicate that the volume is getting higher when we increase the range value. So we are going to take the code we were using at the end of the Starting Out section, and add a linear-gradient to it. Remember, these lines won’t be blurry. We’ll need to make sure that the percentage of the gradient matches.

input[type=range]::-webkit-slider-runnable-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  background: #464646;
  background: linear-gradient(    to bottom right,    transparent 50%,    #464646 50%  );}

input[type=range]::-moz-range-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  background: #464646;
  background: linear-gradient(    to bottom right,    transparent 50%,    #464646 50%  );}

input[type=range]::-ms-track {
  height: 0.9rem;
  margin: 0;
  width: 100%;
  cursor: pointer;
  color: transparent;
  background: #464646;
  border: 0;
  background: linear-gradient(    to bottom right,    transparent 50%,    #464646 50%  );}

Now, this looks like a right triangle, with the 90degrees mark being at the bottom right-hand corner.

A dark grey right triangle, the right angle being the bottom right. The thumb is the original round thumb.

My SCSS linter will yell at me about having 2 background properties. But I like having the background as a solid color as a fallback. If the browser doesn’t support linear-gradient, we have a browser fallback.

Designing the thumb

Now, with my goal, I want the thumb to be narrow and taller. CSS wise, this is relatively simple. Here’s the list of the pseudo-elements to make sure the thumb works across browsers:

  • ::-webkit-slider-thumb
  • ::-moz-range-thumb
  • ::-ms-thumb

Again, we have to separate these all out into their separate selectors, the same as the track.

We want to set the -webkit-appearance: none; and set height, width, border, and background. For Chrome, we add a negative margin-top to balance out the positioning. The rest have no margin-top.

input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 0.5rem;
  background: #ffffff;
  border: 1px solid;
  margin-top: -5px;
}

input[type='range']::-moz-range-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 0.5rem;
  background: #ffffff;
  border: 1px solid;
  margin-top: 0;
}

input[type='range']::-ms-thumb {
  -webkit-appearance: none;
  height: 0.75rem;
  width: 0.5rem;
  background: #ffffff;
  border: 1px solid;
  margin-top: 0;
}

Now we will see the track is tall and white with a black border.

A dark grey right triangle, the right angle being the bottom right. There is a tall yet narrow rectangular thumb.

Now we can just round out the edges and add a pointer cursor.

input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 0.5rem;
  background: #ffffff;
  border: 1px solid;
  margin-top: -5px;
  border-radius: 3px;  cursor: pointer;}

input[type='range']::-moz-range-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 0.5rem;
  background: #ffffff;
  margin-top: 0;
  border-radius: 3px;  cursor: pointer;}

input[type='range']::-ms-thumb {
  -webkit-appearance: none;
  height: 0.75rem;
  width: 0.5rem;
  background: #ffffff;
  border: 1px solid;
  margin-top: 0;
  border-radius: 3px;  cursor: pointer;}

The source that helped me out a lot with this section: CSS Tricks

Adding custom focus states

I want to remove the focus styles (gasp!) from the entire input, and put it on the thumb itself. I couldn’t figure out how to get rid of it on Firefox, so if you know, tweet at me and let me know!

I am going to remove the outline on the entire input, and instead all box shadows to the input range on focus:

input[type='range']:focus {
  outline: none;
}

input[type='range']:focus::-webkit-slider-thumb {
  box-shadow: 0px 0px 7px 3px #0065c4;
}

input[type='range']:focus::-moz-range-thumb {
  box-shadow: 0px 0px 7px 3px #0065c4;
}

input[type='range']:focus::-ms-thumb {
  box-shadow: 0px 0px 7px 3px #0065c4;
}

When it gets hairy

If you want the area between the thumb and the start of the range to be a different color, it can begin to get complicated. This differentiation indicates progress, not to be confused with the progress element.

An audio bar slider with a round white thumb. The left of the thumb is navy blue indicating progess. The right of the thumb is pale blue, indicating non played audio.

Unfortunately, using pseudo-elements, we only have support for Edge, IE, and Firefox. There is the pseudo-element ::-ms-fill-lower and ::-moz-range-progress, but none for Chrome. I’ve played with doing this in JavaScript, but it sounds like a performance nightmare and too much math.

You can check out the CodePen where I use these pseudo-elements.

Conclusion

Thanks for reading and learning with me! I hope I’ve helped you explore and also realize that aesthetic and accessibility are not mutually exclusive. You just have to take some time and think it through.

If you liked this blog post and are interested in learning more about accessibility, take my 10 days of a11y free email course.

Cheers! Have a great week!