Building a cross-browser compatible, multi-handle range slider

Range Inputs are HTML elements which let users select a numeric value between a specified minimum and maximum value. They support single values by default.

As part of the WooCommerce Blocks project we’ve been working on converting WooCommerce widgets to Gutenberg blocks. One of those happens to be a price slider which currently uses jQuery UI. For blocks we’re using React.

Rather than use a library, we were really keen to use native range inputs to keep our dependencies to a minimum, and so the range inputs were semantic and keyboard accessible. Our idea was to overlay 2 range sliders to form a single component.

After working on this, I’d like to share some of my findings. Spoiler alert, the final slider looks like this:

The finished slider

The concept

The concept was to overlay two range sliders on top of each other to create a multi handle range slider component:

  • Position the sliders over one-another using CSS
  • Make the range track transparent so only handles are visible
  • Add the track behind both range sliders so it’s shared

Styling the range sliders in Firefox and Chrome

Cross-browser styling range sliders can be difficult, but not impossible. There are different implementations between IE, Firefox, and Webkit based browsers. I found the following articles extremely valuable:

For the range sliders themselves, we need to reset the styles and disable pointer events (so you can click through the both handles):

.range-slider {
	margin: 0;
	padding: 0;
	border: 0;
	outline: none;
	background: transparent;
	-webkit-appearance: none;
	-moz-appearance: none;
	width: 100%;
	height: 0;
	pointer-events: none; // Prevent mouse interaction on the range slider.
}

For the tracks, we target ::-webkit-slider-runnable-track and ::-moz-range-track (separately, if you try to combine the CSS rules it won’t work) and apply some styles to reset appearance:

.range-slider::-webkit-slider-runnable-track {
	cursor: default;
	height: 1px; /* Required for Samsung internet based browsers */
	outline: 0;
	-webkit-appearance: none;

	// Add custom styles here..
}
.range-slider::-moz-range-track {
	cursor: default;
	-moz-appearance: none;
	outline: 0;
	height: 1px;

	// Add custom styles here..
}

For the handles, I applied an SVG background but you can do whatever you need… Rather than share my full code, I’ll just share the important parts below:

.range-slider::-webkit-slider-thumb {
	 -webkit-appearance: none; // Reset appearance so we can apply backgrounds/borders etc.
	pointer-events: auto; // Re-enable pointer events so the handles can be used.
}

.range-slider:-moz-range-thumb {
	-moz-appearance: none; // Reset appearance so we can apply backgrounds/borders etc.
	pointer-events: auto; // Re-enable pointer events so the handles can be used.
}

Next I needed to style the active portion of the track (the space between the 2 handles).

To do this, I borrowed a solution from this library written by Lea Verou which uses CSS variables (calculated on the JavaScript side) and a background gradient to fill only the middle portion of the range slider wrapper.

.price-slider-progress { // absolutely positioned div
	height: 9px;
	width: 100%;
	position: absolute;
	left: 0;
	top: 0;
	--track-background: linear-gradient(to right, transparent var(--low), var(--range-color) 0, var(--range-color) var(--high), transparent 0) no-repeat 0 100% / 100% 100%;
	--range-color: #a8739d;
	background: var(--track-background);
}

On the JavaScript side, we need to insert those two variables (--high and --low inline so the stylesheet can use those values in the background gradient code above. This is the method I used on render:

getProgressStyle() {
	const { min, max } = this.props;
	const { currentMin, currentMax } = this.state;

	const low = Math.round( 100 * ( ( currentMin - min ) / ( max - min ) ) ) - 0.5;
	const high = Math.round( 100 * ( ( currentMax - min ) / ( max - min ) ) ) + 0.5;

	return {
		'--low': low + '%',
		'--high': high + '%',
	};
}

If using this, you’d need to adapt it for your needs, but essentially we want to calculate where (% from the left) the first slider handle is based on the current range slider value and it’s min/max values, and then repeat this for the second slider.

This is what my styled range input looked like once complete:

Range input component

Some other gotchas I found that may be useful to know:

  • I wanted to offset my handles slightly. I had to align these differently in Firefox vs Chrome. Firefox worked with translate, whereas Chrome supported negative margins.
  • Since I didn’t want to style the default “progress” tracks, I disabled browser styles for ::-webkit-slider-progress and ::-moz-range-progress.
  • Firefox adds extra focus elements around range-inputs so you need to disable this with: ::-moz-focus-outer{border: 0;}

You can see the full code in this Pull Request for the WooCommerce Blocks project.

Supporting Internet Explorer and Edge

Supporting IE11+, and the non-webkit versions of Edge, was a huge challenge. Most libraries I found reverted to showing 2 sliders instead as a fallback.

The background-gradient solution used above did not work in IE/Edge, but since it has extra pseudo elements above and below the slider handle, it was actually quite easy to implement. IE has the following elements in it’s range sliders:

  • ::-ms-track – The range slider track. Give this color: transparent; to get rid of the tick marks shown by default.
  • ::-ms-thumb – The slider handle.
  • ::-ms-fill-lower and ::-ms-fill-upper – These control the progress below and above the handle. You can use this in IE instead of using the background-gradient. In my case, for the first slider I gave the lower portion a background color, and the second slider I gave the upper portion a background color. I left the others transparent. Magic!

Some other gotchas I encountered:

Working around pointer-events

Something which nearly forced me to abandon this experiment…Internet Explorer and Edge do not handle pointer-events in the same way as Firefox and Chrome.

If you disable pointer-events on the range slider, it is impossible to re-enable them for the slider handle. Therefore, the range sliders will not be clickable 🙁

// This won't work 🙁
.slider-range {
	pointer-events: none;
}
.slider-range::-ms-thumb {
	pointer-events: auto;
}

If you leave pointer events enabled, only the first range slider can be clicked 🙁

The workaround I found? Toggling which range slider is on top using z-index and JavaScript. This won’t break our styling either as it doesn’t matter which range-input is on top:

We can determine the co-ordinates of each sliders handle (this was proven earlier with the background-gradient CSS), and we can determine the x-coordinate of the mouse relative to the slider, so we simply work out which is closest to the mouse and move it “up” so the mouse can interact with the slider.

/**
 * Works around an IE issue where only one range selector is visible by changing the display order
 * based on the mouse position.
 *
 * @param {obj} event event data.
 */
findClosestRange( event ) {
	const { max } = this.props;
	const bounds = event.target.getBoundingClientRect();
	const x = event.clientX - bounds.left;
	const minWidth = this.minRange.current.offsetWidth;
	const minValue = this.minRange.current.value;
	const maxWidth = this.maxRange.current.offsetWidth;
	const maxValue = this.maxRange.current.value;

	const minX = minWidth * ( minValue / max );
	const maxX = maxWidth * ( maxValue / max );

	const minXDiff = Math.abs( x - minX );
	const maxXDiff = Math.abs( x - maxX );

	if ( minXDiff > maxXDiff ) {
		this.minRange.current.style.zIndex = 20;
		this.maxRange.current.style.zIndex = 21;
	} else {
		this.minRange.current.style.zIndex = 21;
		this.maxRange.current.style.zIndex = 20;
	}
}

Hoorar! A cross-browser compatible multi-handle range slider completed.

Again, you can find my full solution for this in this Pull Request for the WooCommerce Blocks project. I hope this article proves useful for others attempting this in the future!

Show CommentsClose Comments

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: