This content originally appeared on TPGi and was authored by James Edwards
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 technique for creating custom sliders. It combines 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:
The basic idea
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.
- 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 visual 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 visual 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 visual thumb would be interactive. Since users cannot see the difference, the end result could be very confusing or frustrating:
But if we define matching dimensions, then the whole thumb area will be interactive:
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:
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
- Read this article in ARC (must be an ARC Essentials or Enterprise subscriber)
The post A better way to create custom sliders appeared first on TPGi.
This content originally appeared on TPGi and was authored by James Edwards
James Edwards | Sciencx (2023-03-09T16:41:11+00:00) A better way to create custom sliders. Retrieved from https://www.scien.cx/2023/03/09/a-better-way-to-create-custom-sliders/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.