Two stacked native <input type="range">, one component. The trick: visibility on the right shadow-DOM pseudo-elements lets us merge them into a single double-thumb slider — keyboard support, screen-reader support and form submission all come for free.
Ingrédients
For this recipe, you’ll need:
- 1 Ă—
custom-tagelement of your choosing1 - 1 Ă—
div - 2 Ă—
input[type='range']
<input-minmax>
<div>
<input type="range" />
<input type="range" />
</div>
</input-minmax>
Préparation
Now we stack the two inputs on top of each other.
The two <input> are wrapped in a <div> on purpose: that <div>, in position: relative, is the reference parent for positioning the inputs. Properties set directly on the root (input-minmax {...}) are the ones a consumer of the component will want to tweak — we make sure the children inherit them.
input-minmax {
display: inline-block;
/* arbitrary values */
height: 1em;
width: 10em;
}
input-minmax div {
position: relative;
height: 100%;
width: 100%;
}
input-minmax input {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
Cuisson
So far we haven’t touched the styling — the component still has its native look, and we’ll keep as much of it as we can.
What’s obvious right away: the second input’s track sits on top of the first input’s thumb. To fix this, we’ll lean on visibility: hidden — it hides an element without changing its size, lets us reveal a child while the parent stays hidden, and as a bonus, hidden elements aren’t clickable.
The technique differs across the three browser families.
Le bon plat
- Firefox: you can’t style the track without giving the input a background. Since that changes the input’s appearance, we apply it to both inputs.
- WebKit: hiding
::-webkit-slider-runnable-trackisn’t enough to remove the track’s background — it lives on the input’s root element. Luckily, we can still make a shadow-DOM child visible while its parent stays hidden.
/* firefox */
input-minmax input {
appearance: none;
pointer-events: none;
background: transparent
}
input-minmax input::-moz-range-track {
pointer-events: none;
}
input-minmax input:first-child::-moz-range-track {
background: linear-gradient(grey, grey);
background-repeat: no-repeat;
background-position: center;
background-size: 100% 2px;
}
input-minmax input:last-child::-moz-range-track {
visibility: hidden;
}
input-minmax input::-moz-range-thumb {
visibility: visible;
pointer-events: all;
}
/* webkit */
input-minmax input {
appearance: none;
pointer-events: none;
background: transparent
}
input-minmax input::-webkit-slider-runnable-track {
pointer-events: none;
}
input-minmax input:first-child::-webkit-slider-runnable-track {
background: linear-gradient(grey, grey);
background-repeat: no-repeat;
background-position: center;
background-size: 100% 2px;
}
input-minmax input:last-child::-webkit-slider-runnable-track {
visibility: hidden;
}
input-minmax input::-webkit-slider-thumb {
visibility: visible;
pointer-events: all;
}
Le bon ustensile
Now that we know how to write it in CSS, it’s time to let a preprocessor do the busywork. We can’t union the vendor-prefixed selectors directly — browsers drop the entire rule if any one selector is invalid:
input-minmax input:first-child::-moz-range-thumb,
input-minmax input:first-child::-webkit-slider-thumb,
input-minmax input:first-child::-ms-thumb {
/* ... */
}
Hence this SCSS helper:
@mixin range-track(){
&::-webkit-slider-runnable-track { @content; }
&::-moz-range-track { @content; }
&::-ms-track { @content; }
}
@mixin range-thumb(){
&::-webkit-slider-thumb { @content; }
&::-moz-range-thumb { @content; }
&::-ms-thumb { @content; }
}
Rectifier au goût
Two things still need correcting before we plate.
The max value can drop below the min. The inputs are keyboard-operable, screen readers understand them, and their values are submitted correctly with the form — but nothing stops the thumbs from crossing. A bit of JavaScript clamps them. If JS is disabled, the slider stays usable; you just lose the clamp.
$$('input-minmax').forEach( function( $component ){
var inputs = $$('input[type="range"]', $component)
inputs.forEach( function( $input, i ){
function clamp(){
inputs[+!i].value =
Math[i?'min':'max'](inputs[0].value, inputs[1].value)
}
$input.addEventListener('input', clamp)
clamp()
})
})
The two thumbs are hard to grab when they sit close together. The fix: nudge them apart.
/* all browsers */
input-minmax input {
width: calc(100% - 1em);
&:first-child {
@include range-thumb(){
transform: translateX(-50%);
}
}
&:last-child {
@include range-thumb(){
transform: translateX(50%);
}
}
}
Dressage
Now that <input-minmax> works, we can have some fun with it. We give the element a class so we can override the defaults — a class outranks a tag in specificity.
<input-minmax class="minmax--darkmode">
<div>
<input type="range" name="min" step="5" value="40" />
<input type="range" name="max" step="5" value="80" />
</div>
</input-minmax>
/* a bit of context */
body {
background: #222;
color: #EEE;
}
.minmax--darkmode input {
--track-height: 1ch;
--thumb-height: 2em;
@include range-track() {
appearance: none;
background: black;
height: var(--track-height);
border: 1px solid dimgrey;
border-radius: 2px;
}
}
.minmax--darkmode input {
@include range-thumb() {
appearance: none;
background-color: currentColor;
background-image:
/* gradient shading the bottom */
linear-gradient( to bottom, transparent, rgba(0,0,0,.5) ),
/* and three lil' lines */
linear-gradient( to right, rgba(255,255,255,.33) 50%, rgba(0,0,0,.33) 0 ),
linear-gradient( to right, rgba(255,255,255,.33) 50%, rgba(0,0,0,.33) 0 ),
linear-gradient( to right, rgba(255,255,255,.33) 50%, rgba(0,0,0,.33) 0 );
background-size: cover, 2px 67%, 2px 67%, 2px 67%;
background-repeat: no-repeat;
background-position: 50% 50%, 25% 50%, 50% 50%, 75% 50%;
background-blend-mode: luminosity;
border: none;
border-radius: 2px;
box-shadow: inset 1px 1px 0 rgba(255,255,255,.5), inset -1px -1px 0 rgba(0,0,0,.5);
color: slategrey;
height: var(--thumb-height);
width: 1em;
margin: calc((var(--track-height) - var(--thumb-height)) / 2) 0;
}
}
/* let's get extra-fancy */
.minmax--darkmode input:focus {
outline: none;
}
.minmax--darkmode:focus-within input:first-child {
@include range-track() {
border-color: peru;
}
}
.minmax--darkmode input:active,
.minmax--darkmode input:focus {
@include range-thumb() {
color: peru;
}
}
And that’s it — a double-thumb slider built from two native <input type="range">, kept accessible, and skinnable through one CSS class.
Footnotes
-
The name
input-minmaxis arbitrary. As a convention, name a component after its function, not its look — the look gets named later by classes (.minmax--darkmode, etc.). Using a custom tag also gives the base style a low specificity, which makes those class overrides easy. ↩ -
$$is just a shortcut fordocument.querySelectorAll. I picked it up from blissfulJS. ↩