Evolving custom sliders

This is a technique pulled from KnowledgeBase, a digital accessibility repository available through TPGi’s ARC Platform. KnowledgeBase is maintained and consistently updated by our experts, and can be accessed by anyone with an ARC Essentials or Enterprise tier subscription. Contact us to learn more about KnowledgeBase or the ARC Platform.


This article demonstrates a hybrid technique for creating custom sliders, combining the accessibility and usability benefits of a native range input, with the markup and design flexibility of a pure custom slider.

If you’d like to check out the final demo first, here are some links:

Range against the machine

The key to this approach is that an <input type="range"> is still used, but hidden with opacity, so it’s invisible but remains interactive. The appearance of the custom slider is a presentational element that’s positioned directly behind the range input, similar to the technique that’s used for custom checkboxes.

There are two established ways of creating sliders, but they both have issues:

  • You can build custom sliders using ARIA. However that approach is less accessible, because it may not support gesture interaction in mobile screen readers. VoiceOver for iOS and TalkBack for Android both use gestures to interact with native sliders (e.g. single-finger swipe up or down, while the input is focused), but they don’t support those gestures for role="slider". (Although the latest version of VoiceOver does actually fake this support by sending simulated key events, that’s too recent to be a sufficient solution.) ARIA sliders also require a lot of complex scripting, to support all the different ways that users expect to interact with them.
  • You can style native range inputs, far more than with many form controls. That’s possible because browsers implement vendor-specific pseudo-elements to address their parts (e.g. ::-webkit-slider-thumb or ::-moz-range-thumb). But it can still be quite difficult to create a specific design, it requires some doubling-up of CSS rules, and is limited to the available pseudo-elements.

However this approach avoids those issues:

  • All interactions are on the native input, therefore all native interaction methods are supported. This includes mobile screen reader gestures, keyboard navigation, voice interaction, and desktop screen reader shortcuts for navigating form controls. And none of that needs to be scripted.
  • Styling is just as easy as a pure custom slider, because the visible parts are just <span> elements, and further inner elements can be added as required.

The slider also submits its value in form data, and already exposes the appropriate role and state information to assistive technology, just like any form control.

HTML

The basic HTML for this pattern is a standard <input type="range"> with associated <label>. The input is followed by an empty <span> to create the visible slider, then both are wrapped in a container, which provides a positioning context and an identifier for the scripting:

<label for="volume">Volume</label>
<span class="range-slider">
    <input id="volume" type="range" min="0" max="10" step="1" value="8">
    <span class="slider" aria-hidden="true">
        <span class="thumb"></span>
    </span>
</span>

The .slider element has aria-hidden="true" because it’s purely presentational. It will create the visual appearance of a slider, but all the semantics, states and behaviors are conveyed by the range input.

CSS

The CSS hides the native range input using opacity:0, then styles the presentational elements to appear like a slider in the same position:

.range-slider {
    display: inline-block;
    max-width: var(--slider-max-width);
    position: relative;
}

.range-slider > .slider {
    border: var(--slider-border) solid;
    display: block;
    height: var(--slider-height);
    max-width: var(--slider-max-width);
    position: relative;
    width: var(--slider-width);
    z-index: 1;
}
.range-slider > input[type="range"]:focus + .slider {
    outline: 2px solid;
    outline-offset: 2px;
}

.range-slider > input[type="range"] {
    -webkit-appearance: none;
    height: var(--slider-height);
    margin: var(--slider-border);
    max-width: var(--slider-max-width);
    opacity: 0;
    position: absolute;
    width: var(--slider-width);
    z-index: 2;
}

The visually-hidden <input> has the same dimensions and superimposed position as the visible slider, which means that for users, it will just seem like the visible slider itself is interactive. It responds to pointer events, such as clicking on the track, dragging the thumb, or using gestures to change the value. And it can still receive keyboard focus and respond to keyboard interactions, like arrow keys, Page keys, Home and End.

This also means that the visual cursor tracking in screen readers will match the position and dimensions of the visible element. And that auto-scroll behavior in browsers and screen magnification software can correctly determine the position of the slider, if they need to scroll it into view when it receives focus.

Note how :focus indication is implemented with an adjacent-sibling selector from the range input:

.range-slider > input[type="range"]:focus + .slider {
    outline: 2px solid;
    outline-offset: 2px;
}

In general terms, visually-hidden content should not be focusable, or must become visible when it does take focus, otherwise sighted keyboard users would be able to Tab to an element they can’t see. However in this case, a separate visible element is used to convey the focus state, so there’s no loss of keyboard accessibility.

Further selectors could be added to implement :hover or :active states:

.range-slider > input[type="range"]:hover + .slider {
    /* hover styles */
}
.range-slider > input[type="range"]:active + .slider {
    /* active styles */
}

Or to style its disabled state:

.range-slider > input[type="range"]:disabled + .slider {
    opacity: 0.3;
}

A note on the use of CSS variables

Many of the layout values are co-dependent; for example, the dimensions of the track element must precisely match the dimensions of the range input, so that pointer events on the input translate to the same point on the visible slider.

To manage that, the core values are saved to CSS variables, which makes it easier to see where the co-dependencies are, and easier to modify the slider’s layout without having to think about them:

:root {
    --slider-border: 2px;
    --slider-height: 2rem;
    --slider-max-width: 100%;
    --slider-width: 20rem;
    --thumb-margin: 0.2rem;
    --thumb-size: calc(var(--slider-height) - (2 * var(--thumb-margin)));
}

CSS for the inner thumb

The .slider CSS only creates the outer track element. The appearance of the inner thumb (the part that moves when you interact with the slider) is created using two inner elements:

.range-slider > .slider > .thumb {
    display: block;
    height: var(--slider-height);
    position: absolute;
    width: var(--slider-height);
}
.range-slider > .slider > .thumb::before {
    border: 2px solid;
    box-sizing: border-box;
    content: "";
    display: block;
    height: var(--thumb-size);
    margin: var(--thumb-margin);
    width: var(--thumb-size);
}

Using an inner pseudo-element to create the visual appearance of the thumb provides a lot more ease and flexibility in its styling. This can be used, for example, to add margin space around it, without affecting the position or size of the .thumb element.

The thumb position will be calculated as a percentage offset from its parent track, so that it’s flexible to real-time changes in the slider’s dimensions (e.g. from zoom or font-size). But we don’t need to manually account for that, because the proportions are always the same — 50% of the context width is always 50%, regardless of what that width actually is, or how the track or inner thumb is otherwise styled.

But as a consequence of the stacking order we’ve created to do that, the range input position must be offset by the track element’s border width, because it’s positioned inside a different stacking context:

.range-slider {
    position: relative;
    ...
}

.range-slider > input[type="range"] {
    margin: var(--slider-border);
    position: absolute;
    z-index: 2;
}

.range-slider > .slider {
    border: var(--slider-border) solid;
    position: relative;
    z-index: 1;
    ...
}

.range-slider > .slider > .thumb {
    position: absolute;
    ...
}

The position of the slider’s thumb is relative to the .slider element, but the position of the range input is relative to the .range-slider container. This makes the range input sit on top of the track’s border, while the visible thumb sits inside the border. So a margin is used to keep them in the same position.

This difference in stacking context is also why the visible track and range input have explicit z-index values, otherwise the range input would end up behind the visible track, and would therefore not receive any pointer events.

Finally, we need to apply some basic styling to the native thumb, doubled-up to support the two relevant variations of vendor syntax:

.range-slider > input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    display: block;
    height: var(--slider-height);
    width: var(--slider-height);
}
.range-slider > input[type="range"]::-moz-range-thumb {
    display: block;
    height: var(--slider-height);
    width: var(--slider-height);
}

It’s essential that the native thumb is the same size as the visible thumb, so that pointer users can see where to grab it. If we didn’t do that, then the native thumb would be much smaller by default, and only that part of the visible thumb would be interactive. Since users cannot see the difference, the end result could be very confusing or frustrating:

A wide rectangle with a black border representing the slider. Inside that is a smaller red square positioned in the center, representing the visible thumb. Inside the red square is a much smaller blue dot, positioned in the middle, and representing the range input's thumb.
The red square is the visible thumb, and the blue dot is the range input’s thumb, with the size it would have by default (in Mac/Chrome). Only the blue dot is interactive, therefore pointer events that are inside the red square, but outside the blue dot, wouldn’t do anything.

But if we define matching dimensions, then the whole thumb area will be interactive:

A wide rectangle with a black border representing the slider. Inside that is a smaller blue square positioned in the center, representing the range input's thumb.
The blue square is the range input’s thumb, when it’s styled to have the same dimensions as the visible thumb.

JavaScript

The JavaScript for this pattern is refreshingly simple.

Since all interactions are on the native range input, all we need to do is listen for its input and change events, then use the current value to calculate the visible slider’s thumb position:

const updateSlider = (range) => {

    const slider = range.nextElementSibling;
    const thumb = slider.firstElementChild;

    const distance = (range.value / range.max);
    const offset = (thumb.offsetWidth / slider.offsetWidth) * distance;
    const position = Math.floor((distance - offset) * 100);

    thumb.style.insetInlineStart = position + '%';

    if(typeof(thumb.style.insetInlineStart) == 'undefined') {
        thumb.style.left = position + '%';
    }
}

for(const type of ['input','change']) {
    document.addEventListener(type, (e) => {
        if(e.target.closest('.range-slider')) {
            updateSlider(e.target);
        }
    });
}

The thumb’s position is applied as a percentage of its parent width, calculated from the current and maximum values (i.e. value / max creates a proportionate value from 0 to 1). However that doesn’t quite position the thumb correctly, since the maximum value would translate to 100%, placing the thumb outside the track:

A wide rectangle with a black border representing the slider. Inside that is a smaller red square representing the visible thumb, which is positioned so it extends outside and overlaps the right edge.

To compensate for that, the thumb’s position needs to be adjusted by the same proportion of its own width. For example, a position of 50% would be: 50% of the container width minus 50% of the thumb width. But since the final position value is a percentage of the track width, that thumb adjustment must also be calculated as a proportion of the track, not a proportion of itself:

//basic position as a proportion of the track width
const distance = (range.value / range.max);

//thumb offset as a proportion of the track width
const offset = (thumb.offsetWidth / slider.offsetWidth) * distance;

//convert to final percentage
const position = Math.floor((distance - offset) * 100);

These calculations and positioning are done in response to change and input events. Handling both events is necessary to support all the different ways in which a user might interact with it.

All browsers fire a change event when the range input value is updated. However dragging the slider with a pointer doesn’t update the value (and therefore doesn’t fire any change events) until the pointer is released. But it does fire continual input events, which we can use to maintain the visual thumb position in that case.

Most browsers actually fire another input event when the value updates, at the same time as their change event, so nominally we could have just used input events alone. However iOS/VoiceOver doesn’t fire the input event at all, therefore both events are needed to support all cases.

Finally, there’s some initialization code tied to DOMContentLoaded, that sets the initial position of the slider. This handles cases where the default value isn’t the same as the minimum value (e.g. value="8" where min="0"):

document.addEventListener('DOMContentLoaded', () => {
    const ranges = document.querySelectorAll('.range-slider > input[type="range"]');
    for(const range of ranges) {
        updateSlider(range);
    }
});

Logical positioning

The percentage position of the thumb is applied using a logical property, inset-inline-start, so that it also supports RTL (Right-To-Left) pages:

thumb.style.insetInlineStart = position + '%';

When a slider is used on an RTL page, the position needs to be reversed, i.e. a value of 80% is right:80% rather than left:80%. This is particularly essential for a slider, since the native keyboard interactions are also reversed (e.g. Left Arrow increases the value rather than decreasing it).

If we didn’t translate that difference to the thumb’s position, then it would move in the wrong direction (e.g. moving to the right in response to Left Arrow presses), and the position of the thumb would never match its value unless the value was 50%.

Using this logical property avoids the need to detect that situation, because it translates to either left or right depending on the writing mode.

However it’s not fully supported. Specifically, Safari didn’t add support until Version 14, which is too recent to meet minimum browser support expectations (being less than two years old). We can feature-detect that situation and apply the position using .left instead, so it works by default for most languages, but you will have to manually change this to .right if you’re using it on RTL pages:

//change .left to .right for use on RTL pages
if(typeof(thumb.style.insetInlineStart) == 'undefined') {
    thumb.style.left = position + '%';
}

Interaction

The interactions for this pattern are the same as for any standard <input type="range"> with an associated <label>.

Browser support

This pattern is supported by all modern browsers, in versions that are at least two years old. This is the standard benchmark we use for design patterns in the KnowledgeBase:

  • Chrome 87 or later
  • Firefox 78 or later
  • Safari 13 or later
  • Edge 87 or later

Internet Explorer is not considered a modern browser, and is not supported.

Assistive technology support

There are no known issues relating to the use of assistive technologies.

CodePen Demo

Resources

Categories: KnowledgeBase Content, Technical

About James Edwards

I’m a web accessibility consultant with around 20 years experience. I develop, research and write about all aspects of accessible front-end development, with a particular specialism in accessible JavaScript. I can also turn my hand to PHP and MySQL when it’s needed. I started my career as an HTML coder, then as a JavaScript developer, but the more I learned, the more I realised just how important it is to consider accessibility. It’s the basic foundation of web development and a fundamental design principle of the web itself. If information is not accessible, then what’s the point of any of it? Coding is mechanics, but accessibility is people, and it’s people that actually matter.