⚠️ Warning: this is an old article and may include information that’s out of date. ⚠️

(check out the live demo)

Introduction

After reading “What the Heck is Shadow DOM?” I was inspired to see how far I could style the <input type="range"> element. Pretty far, as it turns out!

My goal was to change the input’s default appearance:

The default appearance for input type=range

To the visual appearance of the iPhone’s unlock slider:

The iPhone's unlock slider

As far as I can tell, only WebKit-based browsers and Opera have implemented input type=range. Mobile Safari hasn’t yet implemented it as of iOS 4.2 and Android also hasn’t fully implemented it as of Android 2.3 (although curiously the elements are styleable but not yet interactive). Surprisingly, the only mobile OS to implement this input type seems to be BlackBerry OS 6.

However, the focus here will be to get this working on just Chrome and WebKit. This will make it a bit easier to do this proof of concept. So we’ll be using lots of -webkit vendor prefixed CSS.

What we have to work with

Of course we have our simple range input:

1
<input type="range" />

Which looks like this:

The default appearance for input type=range

We can target the input itself with a CSS attribute selector:

1
2
input[type="range"] {
}

And if we want, we can transform it into a vertical slider by changing the -webkit-appearance property:

1
2
3
input[type="range"] {
  -webkit-appearance: slider-vertical;
}

Which looks like this:

Input range in a vertical orientation

If we want to style it as if it was any other element, there’s a slight catch, as we first have to set “-webkit-appearance” to “none” and then add our customizations (this appears to mean that we can’t actually style vertical sliders…). Here we’ll set the background color as a simple demo:

1
2
3
4
input[type="range"] {
  -webkit-appearance: none;
  background-color: gray;
}

This outputs the following:

Input range with a gray background

Most interestingly, we can target and style the slider button itself with CSS, although we can’t get access to it with JavaScript (for the time being anyhow). Here we’ll style it a slightly darker shade of gray:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
input[type="range"] {
  -webkit-appearance: none;
  background-color: gray;
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  background-color: #444;
  width: 15px;
  height: 20px;
}

Which results in this:
Input range with a gray background and a slightly darker slider control

Now all the pieces are in place to fully style the input!

Styling the background

This part’s no big deal. We just want to set up the width/height, slap on some handy rounded corners, and add a very slight background gradient:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
input[type="range"] {
  -webkit-appearance: none;
  width: 280px;
  height: 46px;
  padding: 3px;
  -webkit-border-radius: 15px;
  border-radius: 15px;
  border: 1px solid #525252;
  background-image: -webkit-gradient(
    linear,
    left top,
    left bottom,
    color-stop(0, #000000),
    color-stop(1, #222222)
  );
}

Now we have the backdrop in place:

iPhone slider background

Styling the button

To get the button, we’ll add some width/height and rounded corners. Here we’ll also need a more complicated background gradient as well as an arrow icon. Your first instinct might be to add more markup to hook the arrow icon to, but remember that this is impossible. We’re working in the “shadow DOM” of elements created and maintained by the browser itself. We can style the existing elements but we can’t create any new ones.

So what do we do? Take advantage of multiple backgrounds! We can specify the background as both the arrow image and the gradient we need. In this case we’ll also base64 encode the small image to avoid an extra HTTP request.

The CSS for the button looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 68px;
  height: 44px;
  -webkit-border-radius: 10px;
  border-radius: 10px;
  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACEAAAAYCAYAAAB0kZQKAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAASJJREFUeNpi7OnpYaAC0AXiF0D8mhzNTAzUASBHnAdim4F0BAhIA/EBIC4aSEeAADMQ9wLxRiDmHyhHwIAfNHqMiXZEcXExGJMCiNCjCMTHgDiTkFmM////p4rXe3t78Rm0DIjTgfgLNkkWoGZQij7MQFsQBY2aICC+Rq80gQ2oA/EZIE4YSEeAACcQzwfimVD2gDgCBtKgiVZlIB0BAgbQbBwykI5A5I4BtPsaNLfcHKiQWADEJiAHDERIfAfiLKgjBiQ67kCD/zK2NAFqjMyi0AJQVnPCI78GiBNxFttQF6ZToVjG5ohfoLoOiKcMVO54BA3+swPVntgKxIbEOIAWjvgLxJVA7APE7waisHoKxBFAfGSgSszL0MLnBTmaAQIMAKg/OsrT7JG8AAAAAElFTkSuQmCC"),
    -webkit-gradient(linear, left top, left bottom, color-stop(0, #fefefe), color-stop(0.49, #dddddd), color-stop(0.51, #d1d1d1), color-stop(1, #a1a1a1));
  background-repeat: no-repeat;
  background-position: 50%;
}

Which outputs this:

“Slide to unlock” text

Now we’re getting somewhere! And now we’re approaching the limits of what we can do without getting pretty creative. We need some “slide to unlock” text in the background.

The first thing to do is to move the slider all the way to the left, to the default position. We do this through HTML by setting the value to 0:

1
<input type="range" value="0" />

Ok, that wasn’t too hard. But what about the text? We can’t modify anything in the input itself, because it doesn’t contain any element to display text. And we can’t dynamically add text with JavaScript, because again, it’s the shadow DOM! What we can do is create a separate text element outside of the input and position it on top of the slider using absolute positioning.

But this would be no good, since the text would appear over the button itself. What we want is for the text to show up in-between the button and the background. Since we can style both of these with CSS, we can control the stacking order with good old z-index!

And while we’re at it, we might as well animate the “spotlight” effect on the text. We can do this with a combination of a semitransparent -webkit-mask and animations (see below).

First we have the HTML, which has changed a bit. For positioning and grouping, we need a wrapped element for the input and the span of text:

1
2
3
4
<div class="iphone-slider">
    <input type="range" value="0"></input>
    <span>slide to unlock</span>
</div>

The final complete CSS is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
.iphone-slider {
  width: 280px;
  height: 46px;

  /* set the wrapper as the anchor element for positioning */
  position: relative;
}

.iphone-slider input {
  -webkit-appearance: none;
  width: 100%;
  background: #ddd;
  padding: 3px;
  border: 1px solid #525252;
  -webkit-border-radius: 15px;
  border-radius: 15px;
  background-image: -webkit-gradient(
    linear,
    left top,
    left bottom,
    color-stop(0, #000000),
    color-stop(1, #222222)
  );
}

.iphone-slider input::-webkit-slider-thumb {
  -webkit-appearance: none;

  /* position the button on top of everything */
  z-index: 100;
  position: relative;

  width: 68px;
  height: 44px;
  -webkit-border-radius: 10px;
  border-radius: 10px;

  /* arrow and button gradient */
  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACEAAAAYCAYAAAB0kZQKAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAASJJREFUeNpi7OnpYaAC0AXiF0D8mhzNTAzUASBHnAdim4F0BAhIA/EBIC4aSEeAADMQ9wLxRiDmHyhHwIAfNHqMiXZEcXExGJMCiNCjCMTHgDiTkFmM////p4rXe3t78Rm0DIjTgfgLNkkWoGZQij7MQFsQBY2aICC+Rq80gQ2oA/EZIE4YSEeAACcQzwfimVD2gDgCBtKgiVZlIB0BAgbQbBwykI5A5I4BtPsaNLfcHKiQWADEJiAHDERIfAfiLKgjBiQ67kCD/zK2NAFqjMyi0AJQVnPCI78GiBNxFttQF6ZToVjG5ohfoLoOiKcMVO54BA3+swPVntgKxIbEOIAWjvgLxJVA7APE7waisHoKxBFAfGSgSszL0MLnBTmaAQIMAKg/OsrT7JG8AAAAAElFTkSuQmCC"),
    -webkit-gradient(linear, left top, left bottom, color-stop(0, #fefefe), color-stop(0.49, #dddddd), color-stop(0.51, #d1d1d1), color-stop(1, #a1a1a1));
  background-repeat: no-repeat;
  background-position: 50%;
}

.iphone-slider span {
  /* position the text just under the button in the stacking order */
  position: absolute;
  z-index: 99;
  top: 30%;
  left: 37%;

  font-family: "Helvetica Neue", Helvetica, sans;
  font-size: 24px;
  color: white;
  cursor: default;
  -webkit-user-select: none;

  /* semitransparent gradient mask to animate over the text */
  -webkit-mask-image: -webkit-gradient(
    linear,
    left top,
    right top,
    color-stop(0, rgba(0, 0, 0, 0.3)),
    color-stop(0.45, rgba(0, 0, 0, 0.3)),
    color-stop(0.5, rgba(0, 0, 0, 1)),
    color-stop(0.55, rgba(0, 0, 0, 0.3)),
    color-stop(1, rgba(0, 0, 0, 0.3))
  );
  -webkit-mask-size: 1000px;
  -webkit-mask-repeat: no-repeat;
  -webkit-animation-timing-function: ease-in-out;
  -webkit-animation: text-spotlight 4s infinite;
}

/* animate the webkit-mark over the text */
@-webkit-keyframes text-spotlight {
  0% {
    -webkit-mask-position: -800px;
  }

  100% {
    -webkit-mask-position: 0px;
  }
}

This renders as the following:

Which looks pretty dang close the the native iPhone slider if I do say so myself! (perhaps the arrow could use some box-shadow to make it pop a bit more however).

Just a bit of JavaScript

Since we’ve come this far, we might as well make the experience as authentic as possible. Here I’ve thrown in some JavaScript to check if the slider has been fully moved to the right, giving the user notification that the phone is unlocked.

Also, you’ll notice the opacity of the text slowly fade as the slider is moved farther to the right. This mimics the actual experience on the iPhone.

Here’s the JavaScript to make this work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
(function () {
  // variable declarations
  var slider,
    sliderInput,
    sliderButton,
    sliderText,
    sliderTimeout,
    sliderOnchange,
    unlockCheck;

  // cache our DOM elements in variables
  slider = document.querySelector(".iphone-slider");
  sliderInput = slider.querySelector("input");
  sliderButton = sliderInput.querySelector("::-webkit-slider-thumb");
  sliderText = slider.querySelector("span");

  /*
      Check if it's been unlocked, else return the
      button back to the left side (default position).
  */
  unlockCheck = function () {
    if (sliderInput.value == 100) {
      sliderText.innerHTML = "Unlocked!";
      sliderInput.value = 0;
      sliderText.style.opacity = 1;
    } else {
      setTimeout(function () {
        sliderInput.value = 0;
        sliderText.style.opacity = 1;
      }, 1000);
    }
  };

  sliderOnchange = function () {
    /*
        Set the opacity of the text relative to the value
        on the input range.
    */
    sliderText.style.opacity = (100 - sliderInput.value) / 200;

    /*
        Add a timeout to prevent the function from being called
        on EVERY onchange event.
    */
    clearTimeout(sliderTimeout);
    sliderTimeout = setTimeout(unlockCheck, 300);
  };

  slider.onchange = sliderOnchange;
})();

Live example

Here’s a video of the slider in action:

If you’re running Safari or Chrome, you should be able to see and interact with the standalone demo.

Sweet! Well, that was fun! This was some good practice of using a ton of good stuff: border radius, gradients, multiple backgrounds, data URI images, webkit-mask-image, webkit-animation, and of course styleable input ranges via webkit-slider-thumb.

UPDATE: Chrome has now made the shadow DOM inspectable.