Multi-Thumb Sliders: Particular Two-Thumb Case
Publikováno: 7.1.2020
This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is that CSS got better — and so have I, so I recently decided to make my own 2019 version.
In this two-part article, we'll go through the how, step-by-step, first building an example with two thumbs, then identify the issues … Read article
The post Multi-Thumb Sliders: Particular Two-Thumb Case appeared first on CSS-Tricks.
This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is that CSS got better — and so have I, so I recently decided to make my own 2019 version.
In this two-part article, we'll go through the how, step-by-step, first building an example with two thumbs, then identify the issues with it. We'll solve those issues, first for the two-thumb case then, in part two, come up with a better solution for the multi-thumb case.
Note how the thumbs can pass each other and we can have any possible order, with the fills in between the thumbs adapting accordingly. Surprisingly, the entire thing is going to require extremely little JavaScript.
Article Series:
- Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
- Multi-Thumb Sliders: General Case (Coming Tomorrow!)
Basic structure
We need two range inputs inside a wrapper. They both have the same minimum and maximum value (this is very important because nothing is going to work properly otherwise), which we set as custom properties on the wrapper (--min
and --max
). We also set their values as custom properties (--a
and --b
).
- let min = -50, max = 50
- let a = -30, b = 20;
.wrap(style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
input#a(type='range' min=min value=a max=max)
input#b(type='range' min=min value=b max=max)
This generates the following markup:
<div class='wrap' style='--a: -30; --b: 20; --min: -50; --max: 50'>
<input id='a' type='range' min='-50' value='-30' max='50'/>
<input id='b' type='range' min='-50' value='20' max='50'/>
</div>
Accessibility considerations
We have two range inputs and they should probably each have a <label>
, but we want our multi-thumb slider to have a single label. How do we solve this issue? We can make the wrapper a <fieldset>
, use its <legend>
to describe the entire multi-thumb slider, and have a <label>
that's only visible to screen readers for each of our range inputs. (Thanks to Zoltan for this great suggestion.)
But what if we want to have a flex
or grid
layout on our wrapper? That's something we probably want, as the only other option is absolute positioning and that comes with its own set of issues. Then we run into a Chromium issue where <fieldset>
cannot be a flex
or grid
container.
To go around this, we use the following ARIA equivalent (which I picked up from this post by Steve Faulkner):
- let min = -50, max = 50
- let a = -30, b = 20;
.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
#multi-lbl Multi thumb slider:
label.sr-only(for='a') Value A:
input#a(type='range' min=min value=a max=max)
label.sr-only(for='b') Value B:
input#b(type='range' min=min value=b max=max)
The generated markup is now:
<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
<div id='multi-lbl'>Multi thumb slider:</div>
<label class='sr-only' for='a'>Value A:</label>
<input id='a' type='range' min='-50' value='-30' max='50'/>
<label class='sr-only' for='b'>Value B:</label>
<input id='b' type='range' min='-50' value='20' max='50'/>
</div>
If we set an aria-label
or an aria-labelledby
attribute on an element, we also need to give it a role
.
Basic styling
We make the wrapper a middle-aligned grid
with two rows and one column. The bottom grid cell gets the dimensions we want for the slider, while the top one gets the same width as the slider, but can adjust its height according to the group label's content.
$w: 20em;
$h: 1em;
.wrap {
display: grid;
grid-template-rows: max-content $h;
margin: 1em auto;
width: $w;
}
To visually hide the <label>
elements, we absolutely position
them and clip them to nothing:
.wrap {
// same as before
overflow: hidden; // in case <label> elements overflow
position: relative;
}
.sr-only {
position: absolute;
clip-path: inset(50%);
}
Some people might shriek about clip-path
support, like how using it cuts out pre-Chromium Edge and Internet Explorer, but it doesn't matter in this particular case! We're getting to the why behind that in a short bit.
We place the sliders, one on top of the other, in the bottom grid cell:
input[type='range'] {
grid-column: 1;
grid-row: 2;
}
See the Pen by thebabydino (@thebabydino) on CodePen.
We can already notice a problem however: not only does the top slider track show up above the thumb of the bottom one, but the top slider makes it impossible for us to even click and interact with the bottom one using a mouse or touch.
In order to fix this, we remove any track backgrounds and borders and highlight the track area by setting a background
on the wrapper instead. We also set pointer-events: none
on the actual <input>
elements and then revert to auto
on their thumbs.
@mixin track() {
background: none; /* get rid of Firefox track background */
height: 100%;
width: 100%;
}
@mixin thumb() {
background: currentcolor;
border: none; /* get rid of Firefox thumb border */
border-radius: 0; /* get rid of Firefox corner rounding */
pointer-events: auto; /* catch clicks */
width: $h; height: $h;
}
.wrap {
/* same as before */
background: /* emulate track with wrapper background */
linear-gradient(0deg, #ccc $h, transparent 0);
}
input[type='range'] {
&::-webkit-slider-runnable-track,
&::-webkit-slider-thumb, & { -webkit-appearance: none; }
/* same as before */
background: none; /* get rid of white Chrome background */
color: #000;
font: inherit; /* fix too small font-size in both Chrome & Firefox */
margin: 0;
pointer-events: none; /* let clicks pass through */
&::-webkit-slider-runnable-track { @include track; }
&::-moz-range-track { @include track; }
&::-webkit-slider-thumb { @include thumb; }
&::-moz-range-thumb { @include thumb; }
}
Note that we've set a few more styles on the input
itself as well as on the track and thumb in order to make the look consistent across the browsers that support letting clicks pass through the actual input
elements and their tracks, while allowing them on the thumbs. This excludes pre-Chromium Edge and IE, which is why we haven't included the -ms-
prefix — there's no point styling something that wouldn't be functional in these browsers anyway. This is also why we can use clip-path
to hide the <label>
elements.
If you'd like to know more about default browser styles in order to understand what's necessary to override here, you can check out this article where I take an in-depth look at range inputs (and where I also detail the reasoning behind using mixins here).
See the Pen by thebabydino (@thebabydino) on CodePen.
Alright, we now have something that looks functional. But in order to really make it functional, we need to move on to the JavaScript!
Functionality
The JavaScript is pretty straightforward. We need to update the custom properties we've set on the wrapper. (For an actual use case, they'd be set higher up in the DOM so that they're also inherited by the elements whose styles that depend on them.)
addEventListener('input', e => {
let _t = e.target;
_t.parentNode.style.setProperty(`--${_t.id}`, +_t.value)
}, false);
See the Pen by thebabydino (@thebabydino) on CodePen.
However, unless we bring up DevTools to see that the values of those two custom properties really change in the style
attribute of the wrapper .wrap
, it's not really obvious that this does anything. So let's do something about that!
Showing values
Something we can do to make it obvious that dragging the thumbs actually changes something is to display the current values. In order to do this, we use an output
element for each input
:
- let min = -50, max = 50
- let a = -30, b = 20;
.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
#multi-lbl Multi thumb slider:
label.sr-only(for='a') Value A:
input#a(type='range' min=min value=a max=max)
output(for='a' style='--c: var(--a)')
label.sr-only(for='b') Value B:
input#b(type='range' min=min value=b max=max)
output(for='b' style='--c: var(--b)')
The resulting HTML looks as follows:
<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
<div id='multi-lbl'>Multi thumb slider:</div>
<label class='sr-only' for='a'>Value A:</label>
<input id='a' type='range' min='-50' value='-30' max='50'/>
<output for='a' style='--c: var(--a)'></output>
<label class='sr-only' for='b'>Value B:</label>
<input id='b' type='range' min='-50' value='20' max='50'/>
<output for='b' style='--c: var(--b)'></output>
</div>
We display the values in an ::after
pseudo-element using a little counter
trick:
output {
&::after {
counter-reset: c var(--c);
content: counter(c);
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
It's now obvious these values change as we drag the sliders, but the result is ugly and it has messed up the wrapper background
alignment, so let's add a few tweaks! We could absolutely position the <output>
elements, but for now, we simply squeeze them in a row between the group label and the sliders:
.wrap {
// same as before
grid-template: repeat(2, max-content) #{$h}/ 1fr 1fr;
}
[id='multi-lbl'] { grid-column: 1/ span 2 }
input[type='range'] {
// same as before
grid-column: 1/ span 2;
grid-row: 3;
}
output {
grid-row: 2;
&:last-child { text-align: right; }
&::after {
content: '--' attr(for) ': ' counter(c) ';'
counter-reset: c var(--c);
}
}
Much better!
See the Pen by thebabydino (@thebabydino) on CodePen.
Setting separate :focus
styles even gives us something that doesn't look half bad, plus allows us to see which value we're currently modifying.
input[type='range'] {
/* same as before */
z-index: 1;
&:focus {
z-index: 2;
outline: dotted 1px currentcolor;
&, & + output { color: darkorange }
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
All we need now is to create the fill between the thumbs.
The tricky part
We can recreate the fill with an ::after
pseudo-element on the wrapper, which we place on the bottom grid row where we've also placed the range inputs. This pseudo-element comes, as the name suggests, after the inputs, but it will still show up underneath them because we've set positive z-index
values on them. Note that setting the z-index
works on the inputs (without explicitly setting their position
to something different from static
) because they're grid
children.
The width
of this pseudo-element should be proportional to the difference between the higher input
value and the lower input
value. The big problem here is that they pass each other and we have no way of knowing which has the higher value.
First approach
My first idea on how to solve this was by using width
and min-width
together. In order to better understand how this works, consider that we have two percentage values, --a
and --b
, and we want to make an element's width
be the absolute value of the difference between them.
Either one of the two values can be the bigger one, so we pick an example where --b
is bigger and an example where --a
is bigger:
<div style='--a: 30%; --b: 50%'><!-- first example, --b is bigger --></div>
<div style='--a: 60%; --b: 10%'><!-- second example, --a is bigger --></div>
We set width
to the second value (--b
) minus the first (--a
) and min-width
to the first value (--a
) minus the second one (--b
).
div {
background: #f90;
height: 4em;
min-width: calc(var(--a) - var(--b));
width: calc(var(--b) - var(--a));
}
If the second value (--b
) is bigger, then the width
is positive (which makes it valid) and the min-width
negative (which makes it invalid). That means the computed value is the one set via the width
property. This is the case in the first example, where --b
is 70%
and --a
is 50%
. That means the width
computes to 70% - 50% = 20%
, while the min-width
computes to 50% - 70% = -20%
.
If the first value is bigger, then the width
is negative (which makes it invalid) and the min-width
is positive (which makes it valid), meaning the computed value is that set via the min-width
property. This is the case in the second example, where --a
is 80%
and --b
is 30%
, meaning the width
computes to 30% - 80% = -50%
, while the min-width
computes to 80% - 30% = 50%
.
See the Pen by thebabydino (@thebabydino) on CodePen.
Applying this solution for our two thumb slider, we have:
.wrap {
/* same as before */
--dif: calc(var(--max) - var(--min));
&::after {
content: '';
background: #95a;
grid-column: 1/ span 2;
grid-row: 3;
min-width: calc((var(--a) - var(--b))/var(--dif)*100%);
width: calc((var(--b) - var(--a))/var(--dif)*100%);
}
}
In order to represent the width
and min-width
values as percentages, we need to divide the difference between our two values by the difference (--dif
) between the maximum and the minimum of the range inputs and then multiply the result we get by 100%
.
See the Pen by thebabydino (@thebabydino) on CodePen.
So far, so good... so what?
The ::after
always has the right computed width
, but we also need to offset it from the track minimum by the smaller value and we can't use the same trick for its margin-left
property.
My first instinct here was to use left
, but actual offsets don't work on their own. We'd have to also explicitly set position: relative
on our ::after
pseudo-element in order to make it work. I felt kind of meh about doing that, so I opted for margin-left
instead.
The question is what approach can we take for this second property. The one we've used for the width
doesn't work because there is no such thing as min-margin-left
.
A min()
function is now in the CSS spec, but at the time when I coded these multi-thumb sliders, it was only implemented by Safari (it has since landed in Chrome as well). Safari-only support was not going to cut it for me since I don't own any Apple device or know anyone in real life who does... so I couldn't play with this function! And not being able to come up with a solution I could actually test meant having to change the approach.
Second approach
This involves using both of our wrapper's (.wrap
) pseudo-elements: one pseudo-element's margin-left
and width
being set as if the second value is bigger, and the other's set as if the first value is bigger.
With this technique, if the second value is bigger, the width
we're setting on ::before
is positive and the one we're setting on ::after
is negative (which means it's invalid and the default of 0
is applied, hiding this pseudo-element). Meanwhile, if the first value is bigger, then the width
we're setting on ::before
is negative (so it's this pseudo-element that has a computed width
of 0
and is not being shown in this situation) and the one we're setting on ::after
is positive.
Similarly, we use the first value (--a
) to set the margin-left
property on the ::before
since we assume the second value --b
is bigger for this pseudo-element. That means --a
is the value of the left end and --b
the value of the right end.
For ::after
, we use the second value (--b
) to set the margin-left
property, since we assume the first value --a
is bigger this pseudo-element. That means --b
is the value of the left end and --a
the value of the right end.
Let's see how we put it into code for the same two examples we previously had, where one has --b
bigger and another where --a
is bigger:
<div style='--a: 30%; --b: 50%'></div>
<div style='--a: 60%; --b: 10%'></div>
div {
&::before, &::after {
content: '';
height: 5em;
}
&::before {
margin-left: var(--a);
width: calc(var(--b) - var(--a));
}
&::after {
margin-left: var(--b);
width: calc(var(--a) - var(--b));
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
Applying this technique for our two thumb slider, we have:
.wrap {
/* same as before */
--dif: calc(var(--max) - var(--min));
&::before, &::after {
grid-column: 1/ span 2;
grid-row: 3;
height: 100%;
background: #95a;
content: ''
}
&::before {
margin-left: calc((var(--a) - var(--min))/var(--dif)*100%);
width: calc((var(--b) - var(--a))/var(--dif)*100%)
}
&::after {
margin-left: calc((var(--b) - var(--min))/var(--dif)*100%);
width: calc((var(--a) - var(--b))/var(--dif)*100%)
}
}
See the Pen by thebabydino (@thebabydino) on CodePen.
We now have a nice functional slider with two thumbs. But this solution is far from perfect.
Issues
The first issue is that we didn't get those margin-left
and width
values quite right. It's just not noticeable in this demo due to the thumb styling (such as its shape, dimensions relative to the track, and being full opaque).
But let's say our thumb is round and maybe even smaller than the track height
:
See the Pen by thebabydino (@thebabydino) on CodePen.
We can now see what the problem is: the endlines of the fill don't coincide with the vertical midlines of the thumbs.
This is because of the way moving the thumb end-to-end works. In Chrome, the thumb's border-box
moves within the limits of the track's content-box
, while in Firefox, it moves within the limits of the slider's content-box
. This can be seen in the recordings below, where the padding
is transparent
, while the content-box
and the border
are semi-transparent. We've used orange for the actual slider, red for the track and purple for the thumb.
Note that the track's width
in Chrome is always determined by that of the parent slider - any width
value we may set on the track itself gets ignored. This is not the case in Firefox, where the track can also be wider or narrower than its parent <input>
. As we can see below, this makes it even more clear that the thumb's range of motion depends solely on the slider width
in this browser.
In our particular case (and, to be fair, in a lot of other cases), we can get away with not having any margin
, border
or padding
on the track. That would mean its content-box
coincides to that of the actual range input
so there are no inconsistencies between browsers.
But what we need to keep in mind is that the vertical midlines of the thumbs (which we need to coincide with the fill endpoints) move between half a thumb width
(or a thumb radius if we have a circular thumb) away from the start of the track and half a thumb width
away from the end of the track. That's an interval equal to the track width
minus the thumb width
(or the thumb diameter in the case of a circular thumb).
This can be seen in the interactive demo below where the thumb can be dragged to better see the interval its vertical midline (which we need to coincide with the fill's endline) moves within.
See the Pen by thebabydino (@thebabydino) on CodePen.
The demo is best viewed in Chrome and Firefox.
The fill width
and margin-left
values are not relative to 100%
(or the track width
), but to the track width
minus the thumb width
(which is also the diameter in the particular case of a circular thumb). Also, the margin-left
values don't start from 0
, but from half a thumb width
(which is a thumb radius in our particular case).
$d: .5*$h; // thumb diameter
$r: .5*$d; // thumb radius
$uw: $w - $d; // useful width
.wrap {
/* same as before */
--dif: calc(var(--max) - var(--min));
&::before {
margin-left: calc(#{$r} + (var(--a) - var(--min))/var(--dif)*#{$uw});
width: calc((var(--b) - var(--a))/var(--dif)*#{$uw});
}
&::after {
margin-left: calc(#{$r} + (var(--b) - var(--min))/var(--dif)*#{$uw});
width: calc((var(--a) - var(--b))/var(--dif)*#{$uw});
}
}
Now the fill starts and ends exactly where it should, along the midlines of the two thumbs:
See the Pen by thebabydino (@thebabydino) on CodePen.
This one issue has been taken care of, but we still have a way bigger one. Let's say we want to have more thumbs, say four:
We now have four thumbs that can all pass each other and they can be in any order that we have no way of knowing. Moreover, we only have two pseudo-elements, so we cannot apply the same techniques. Can we still find a CSS-only solution?
Well, the answer is yes! But it means scrapping this solution and going for something different and way more clever — in part two of this article!
Article Series:
- Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
- Multi-Thumb Sliders: General Case (Coming Tomorrow!)
The post Multi-Thumb Sliders: Particular Two-Thumb Case appeared first on CSS-Tricks.