Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active October 14, 2019 11:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save loilo/b37e941bbab05bcfe2a2c40df2c21612 to your computer and use it in GitHub Desktop.
Save loilo/b37e941bbab05bcfe2a2c40df2c21612 to your computer and use it in GitHub Desktop.
Rangeslider

Rangeslider

This is a small, production-ready Rangeslider library.

Try it out in this CodePen!

I created this because no existing library met all my criteria:

  • no heavy-weight third-party dependencies (i.e. no jQuery)
  • respect HTML5 <input type="range"> restrictions (min, max, step)
  • no unnecessary weight through features that go beyond that default <input type="range"> (e.g. no multiple thumbs)
  • accessible via keyboard
  • accessible via touch
  • accessible to assistive technology
  • stylable "active track" (the part of the track on the left of the thumb)
  • responsive

To be fair, the rangeSlider library gets very close, however its UI mysteriously broke in my project.

Furthermore, this library offers:

  • small size (<2KB minified & gzipped)
  • easy responsive management (sliders always fill full availble width/height)
  • eeeeeasy stylability (see "Styling" section below)
  • orientation (accepted additional feature due to big gain for very little overhead)
  • support for all evergreen browsers + IE 11
  • gaining focus through a label will be redirected to the custom slider

Usage

You have to compile scripts (TypeScript) and styles (Sass) before using them.

The script exposes a Rangeslider class whose constructor accepts a DOM element and some additional options.

const input = document.querySelector('input[type="range"]')
const rangeslider = new Rangeslider(input)

That's it, you've got a beautiful slider!

Styling

The styles of the rangeslider have been crafted carefully to allow the easiest possible adjustment of styles. Since compatibility with IE 11 should be maintained, CSS custom properties (aka "CSS variables") were not a thing to use.

The easiest way to style the component is by adjusting the Sass variables. However, here's how to adjust styles if you don't have access to the Sass variables:

/* Adjust thumb size: */
.rangeslider--horizontal { height: 20px }
.rangeslider--vertical { width: 20px }

/* Adjust track size: */
.rangeslider--horizontal .rangeslider__track { height: 5px }
.rangeslider--vertical .rangeslider__track { width: 5px }

/* Adjust inactive track color: */
.rangeslider__track { background-color: #eee }

/* Adjust active track color: */
.rangeslider__active-track { background-color: blue }

/* Adjust thumb color: */
.rangeslider__thumb { background-color: navy }

Events

The Rangeslider object does not provide a large API. To get notified about value changes, you may just attach an input or change event listener to the original <input>:

input.addEventListener('input', () => {
  // Reacts to any value change
})


input.addEventListener('change', () => {
  // Reacts to value changes after dropping the thumb or caused by keyboard
})

Destroy the slider

Regain the good ol' HTML5 slider:

rangeslider.destroy()

Refresh the slider

Fetch the current value, min, max, step and disabled properties from the original input. Useful if these values have been changed programmatically.

rangeslider.refresh()

Options

The Rangeslider constructor accepts an optional second parameter with options. The following are available:

{
  // The orientation of the slider
  // Default: 'horizontal'
  orientation: 'horizontal' | 'vertical'
  
  // A property name on the `<input>` element to which the `Rangeslider` object is assigned
  // Default: false — don't touch the DOM without explicit instructions
  assignToProperty: false | string | symbol
}

Note that there is no way to change options once the slider was created. If you really need that, you'll have to destroy() and reconstruct the slider.

$track-color: #ddd !default;
$active-track-color: #81c8ff !default;
$thumb-color: #4fa3e4 !default;
$disabled-color: #e5e5e5 !default;
$track-size: 3px !default;
$thumb-size: 16px !default;
.rangeslider--hidden {
opacity: 0;
pointer-events: none;
position: absolute;
}
.rangeslider {
position: relative;
display: flex;
align-items: center;
&--disabled {
pointer-events: none;
}
&--horizontal {
flex-direction: row;
height: $thumb-size;
}
&--vertical {
flex-direction: column;
width: $thumb-size;
height: 100%;
}
&--horizontal &__track {
height: $track-size;
left: 0;
right: 0;
}
&--horizontal &__active-track {
left: 0;
top: 0;
bottom: 0;
width: 0;
}
&--horizontal &__thumb {
transform: translateX(-50%);
height: 100%;
img {
width: auto;
height: 100%;
display: block;
}
}
&--vertical &__track {
width: $track-size;
top: 0;
bottom: 0;
}
&--vertical &__active-track {
left: 0;
right: 0;
bottom: 0;
height: 0;
}
&--vertical &__thumb {
transform: translateY(50%);
width: 100%;
img {
width: 100%;
height: auto;
display: block;
}
}
&__track {
background-color: $track-color;
position: absolute;
}
&__active-track {
background-color: $active-track-color;
position: absolute;
}
&__thumb {
position: absolute;
box-sizing: border-box;
background: $thumb-color;
box-shadow: 0 0 0 3px white;
&:focus {
outline: none;
}
}
&--disabled &__track,
&--disabled &__active-track,
&--disabled &__thumb {
background-color: $disabled-color;
}
&--keyboard-navigating &__thumb:focus {
box-shadow: 0 0 0 3px $active-track-color, 0 0 0 3px white;
}
}
interface RangesliderOptions {
orientation: 'horizontal' | 'vertical'
assignToProperty: false | string | symbol
}
interface RangesliderComputed {
range: number
realmax: number
offsetProp: string
stepDecimals: number
}
class Rangeslider {
protected static defaultOptions: RangesliderOptions = {
assignToProperty: false,
orientation: 'horizontal'
}
protected el: HTMLInputElement
protected dom: HTMLElement
protected thumb: HTMLElement
protected track: HTMLElement
protected activeTrack: HTMLElement
protected originalTabIndex: number
protected min: number
protected max: number
protected step: number
protected value: number
protected disabled: boolean = false
protected options: RangesliderOptions
protected computed: RangesliderComputed
constructor (el: HTMLInputElement, options: Partial<RangesliderOptions> = {}) {
this.el = el
this.setOptions(Object.assign(this.getDefaultOptions(), options))
this.el.addEventListener('focus', this.redirectLabels)
if (options.assignToProperty) {
this.el[options.assignToProperty] = this
}
this.init()
this.grabDomProperties()
}
refresh () {
this.grabDomProperties()
this.updateValue(this.value, true, false)
if (this.disabled) {
this.dom.classList.add('rangeslider--disabled')
this.thumb.tabIndex = -1
} else {
this.dom.classList.remove('rangeslider--disabled')
this.thumb.tabIndex = 0
}
}
protected getDefaultOptions () {
return Object.assign({}, Rangeslider.defaultOptions)
}
protected grabDomProperties () {
this.min = (this.el.min != null && this.el.min.length)
? +this.el.min
: 0
this.max = (this.el.max != null && this.el.max.length)
? +this.el.max
: 100
this.step = (this.el.step != null && this.el.step.length)
? +this.el.step
: 1
this.value = (this.el.value != null && this.el.value.length)
? +this.el.value
: this.restrict((this.max - this.min) / 2)
this.disabled = this.el.disabled
this.compute()
this.el.min = String(this.min)
this.el.max = String(this.max)
this.el.step = String(this.step)
this.el.value = String(this.value)
this.thumb.setAttribute('aria-disabled', String(this.disabled))
this.thumb.setAttribute('aria-valuemin', this.el.min)
this.thumb.setAttribute('aria-valuemax', this.el.min)
this.thumb.setAttribute('aria-valuenow', this.el.value)
}
protected dispatch (type: 'change' | 'input') {
const evt = new Event(type)
this.el.dispatchEvent(evt)
}
protected setOptions (options: RangesliderOptions, dispatchEvents: boolean = true) {
this.options = options
this.compute()
}
protected countDecimals (value: number) {
if (Math.floor(value.valueOf()) === value.valueOf()) return 0
return value.toString().split(".")[1].length || 0
}
protected stepRemainder (value: number) {
const factor = Math.pow(10, this.computed.stepDecimals)
return ((value * factor) % (this.step * factor)) / factor
}
protected compute () {
this.computed = {} as any
this.computed.range = this.max - this.min
this.computed.stepDecimals = this.countDecimals(this.step || 0)
this.computed.realmax = this.max - this.stepRemainder(this.computed.range)
this.computed.offsetProp = this.options.orientation === 'horizontal'
? 'clientX'
: 'clientY'
}
protected restrict (value: number): number {
if (value < this.min) return this.min
if (value > this.computed.realmax) return this.computed.realmax
const remainder = this.stepRemainder(value - this.min)
if (remainder === 0) {
return value
} else {
if (remainder > this.step / 2 && (this.step - remainder) <= this.max) {
return value + (this.step - remainder)
} else {
return value - remainder
}
}
}
protected updateValue (value: number, updateVisuals: boolean = null, dispatchInput: boolean = true) {
const restrictedValue = this.restrict(value)
if (restrictedValue === this.value && updateVisuals !== true) return false
this.value = restrictedValue
this.el.value = String(restrictedValue)
this.thumb.setAttribute('aria-valuenow', this.el.value)
if (updateVisuals !== false) {
this.updateProgress()
}
if (dispatchInput) {
this.dispatch('input')
}
return true
}
protected updateProgress () {
const progress = (this.value - this.min) / this.computed.range * 100
if (this.options.orientation === 'horizontal') {
this.thumb.style.left = `${progress}%`
this.activeTrack.style.width = `${progress}%`
} else {
this.thumb.style.bottom = `${progress}%`
this.activeTrack.style.height = `${progress}%`
}
}
protected redirectLabels = () => {
this.thumb.focus()
}
protected init () {
this.el.classList.add('rangeslider--hidden')
this.originalTabIndex = this.el.tabIndex
this.el.tabIndex = -1
this.dom = document.createElement('div')
this.dom.classList.add('rangeslider')
this.dom.classList.add(`rangeslider--${this.options.orientation}`)
this.thumb = document.createElement('span')
this.thumb.setAttribute('role', 'slider')
this.thumb.classList.add('rangeslider__thumb')
this.thumb.tabIndex = 0
this.thumb.innerHTML = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApocMXEAAAAASUVORK5CYII=">'
if (this.el.id) {
const labelledBy = []
const labelTargetId = this.el.id
.replace(/\n/g, '\\00000a')
.replace(/"/g, '\\000022')
const labels = document.querySelectorAll(`label[id][for="${labelTargetId}"]`)
if (labels.length > 0) {
for (let i = 0; i < labels.length; i++) {
labelledBy.push(labels[i].id)
}
this.thumb.setAttribute('aria-labelledby', labelledBy.join(' '))
}
}
this.thumb.addEventListener('blur', () => {
this.dom.classList.remove('rangeslider--keyboard-navigating')
})
this.thumb.addEventListener('focus', () => {
if (tabWasJustPressed) {
this.dom.classList.add('rangeslider--keyboard-navigating')
}
})
this.thumb.addEventListener('keydown', evt => {
let changed
switch (evt.keyCode) {
// Arrow left & down
case 37:
case 40:
changed = this.updateValue(this.value - this.step)
break
// Arrow right & up
case 39:
case 38:
changed = this.updateValue(this.value + this.step)
break
// Page down
case 34:
changed = this.updateValue(this.value - Math.max(this.step, this.computed.range / 10))
break
// Page up
case 33:
changed = this.updateValue(this.value + Math.max(this.step, this.computed.range / 10))
break
// Home
case 36:
changed = this.updateValue(this.min)
break
// End
case 35:
changed = this.updateValue(this.computed.realmax)
break
default: return
}
evt.preventDefault()
this.dom.classList.add('rangeslider--keyboard-navigating')
if (changed) {
this.dispatch('change')
}
})
this.track = document.createElement('div')
this.track.classList.add('rangeslider__track')
this.activeTrack = document.createElement('div')
this.activeTrack.classList.add('rangeslider__active-track')
this.track.appendChild(this.activeTrack)
this.dom.appendChild(this.track)
this.dom.appendChild(this.thumb)
let tabWasJustPressed = false
let tabWasJustPressedTimeout = null
document.addEventListener('keydown', evt => {
if (evt.keyCode === 9) {
if (tabWasJustPressedTimeout !== null) {
clearTimeout(tabWasJustPressedTimeout)
}
tabWasJustPressedTimeout = setTimeout(() => {
tabWasJustPressed = false
}, 0)
tabWasJustPressed = true
}
})
this.el.insertAdjacentElement('afterend', this.dom)
this.updateProgress()
this.dom.classList[this.disabled ? 'add' : 'remove']('rangeslider--disabled')
this.addTouchHandlers()
this.addMouseHandlers()
}
public destroy () {
this.el.classList.remove('rangeslider--hidden')
this.el.tabIndex = this.originalTabIndex
this.dom.parentNode.removeChild(this.dom)
if (this.options.assignToProperty) {
delete this.el[this.options.assignToProperty]
this.el.removeEventListener('focus', this.redirectLabels)
}
}
protected addMouseHandlers () {
let lowerBoundingPos: number
let upperBoundingPos: number
let oldValue: number
const mouseupHandler = evt => {
if (oldValue !== this.value) {
this.dispatch('change')
}
window.removeEventListener('mousemove', mousemoveHandler)
window.removeEventListener('mouseup', mouseupHandler)
}
const mousemoveHandler = evt => {
const fraction = (evt[this.computed.offsetProp] - lowerBoundingPos) / (upperBoundingPos - lowerBoundingPos)
const value = fraction * this.computed.range + this.min
this.updateValue(value)
}
const mousedownHandler = evt => {
evt.preventDefault()
evt.stopPropagation()
this.thumb.focus()
oldValue = this.value
const clientRect = this.dom.getBoundingClientRect()
if (this.options.orientation === 'horizontal') {
lowerBoundingPos = clientRect.left
upperBoundingPos = clientRect.right
} else {
lowerBoundingPos = clientRect.bottom
upperBoundingPos = clientRect.top
}
window.addEventListener('mousemove', mousemoveHandler)
window.addEventListener('mouseup', mouseupHandler)
}
this.thumb.addEventListener('mousedown', mousedownHandler)
this.dom.addEventListener('mousedown', evt => {
mousedownHandler(evt)
mousemoveHandler(evt)
})
}
protected addTouchHandlers () {
let lowerBoundingPos: number
let upperBoundingPos: number
let oldValue: number
const touchendHandler = evt => {
if (evt.touches.length > 1) return
if (oldValue !== this.value) {
this.dispatch('change')
}
window.removeEventListener('touchmove', touchmoveHandler)
window.removeEventListener('touchend', touchendHandler)
}
const touchmoveHandler = evt => {
if (evt.touches.length > 1) return
const fraction = (evt.touches[0][this.computed.offsetProp] - lowerBoundingPos) / (upperBoundingPos - lowerBoundingPos)
const value = fraction * this.computed.range - this.min
this.updateValue(value)
}
const touchstartHandler = evt => {
if (evt.touches.length > 1) return
oldValue = this.value
const clientRect = this.dom.getBoundingClientRect()
if (this.options.orientation === 'horizontal') {
lowerBoundingPos = clientRect.left
upperBoundingPos = clientRect.right
} else {
lowerBoundingPos = clientRect.bottom
upperBoundingPos = clientRect.top
}
window.addEventListener('touchmove', touchmoveHandler)
window.addEventListener('touchend', touchendHandler)
}
this.thumb.addEventListener('touchstart', touchstartHandler)
this.dom.addEventListener('touchstart', evt => {
touchstartHandler(evt)
touchmoveHandler(evt)
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment