Recipe: a double-thumbed range slider

By styling the shadow DOM

Published on

A range input with two thumbs (inspired by dribbble)

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:

<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%;
}
Stacking the two inputs

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 */
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;
}
Per-browser shadow-DOM styling

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 {
  /* ... */
}
Don’t do this — one invalid selector drops the whole block

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; }
}
Mixins that emit one rule per vendor prefix

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()
  })
})
Min value higher than max value, sanitized with JavaScript2

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%);
    }
  }
}
Offset each thumb so they don’t overlap

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;
  }
}
Final dark-mode skin

And that’s it — a double-thumb slider built from two native <input type="range">, kept accessible, and skinnable through one CSS class.

Footnotes

  1. The name input-minmax is 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. ↩

  2. $$ is just a shortcut for document.querySelectorAll. I picked it up from blissfulJS. ↩