├── README.md ├── app.css ├── app.js └── index.html /README.md: -------------------------------------------------------------------------------- 1 | # JavaScript Concentric Range Slider 2 | 3 | This slider is built with vanilla JS. It's reusable, customizable and easy to use. 4 | 5 | ## Getting started 6 | 7 | To use the range slider plugin, simply include the app.js in your HTML file: 8 | 9 | ``` 10 | 11 | ``` 12 | 13 | Then, create a new range slider instance by calling the Slider constructor and passing in the options: 14 | 15 | ```javascript 16 | const slider = new Slider(options); 17 | ``` 18 | 19 | Pass in the DOM element where you want the slider to appear. Then, pass in as many sliders as you need to the sliders array. Each slider object has several options for customization. 20 | 21 | ```javascript 22 | const options = { 23 | DOMselector: string, 24 | sliders: [ 25 | { 26 | radius: number, 27 | min: number, 28 | max: number, 29 | step: number, 30 | initialValue: number, 31 | color: string, 32 | displayName: string 33 | } 34 | ] 35 | }; 36 | ``` 37 | 38 | * selector -> your container selector (i.e. #mySlider, can be any valid selector) 39 | * sliders -> array of options objects for sliders 40 | * radius -> radius of the slider (i.e. 100) 41 | * min -> minimum value of the slider (i.e. 100) 42 | * max -> maximum value of the slider (i.e. 100) 43 | * step -> value step (i.e. 10) 44 | * initialValue -> value of the slider on initialization (i.e. 50) 45 | * color -> color of the slider (valid hex code value) 46 | * displayName -> name of the legend item (any string) 47 | 48 | Call the draw method() on the new instance of the Slider class. 49 | 50 | ```javascript 51 | slider.draw(); 52 | ``` 53 | 54 | That's it! 55 | 56 | ## Special thanks to 57 | Sabine - for finding a bug in the calculation of the relative client coordinates and pointing it out! 58 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | body { 9 | min-height: 100vh; 10 | background-color: #f8f9fa; 11 | display: flex; 12 | justify-content: center; 13 | font-family: 'Raleway', sans-serif; 14 | } 15 | 16 | .container { 17 | padding: 5% 1.5rem; 18 | max-width: 800px; 19 | width: 100%; 20 | } 21 | 22 | #app { 23 | display: flex; 24 | flex-wrap: wrap; 25 | } 26 | 27 | h1 { 28 | margin-bottom: 2rem; 29 | font-size: 3rem; 30 | } 31 | 32 | .slider__legend { 33 | padding: 1rem 3rem 0 0; 34 | list-style: none; 35 | } 36 | 37 | .slider__legend h2 { 38 | margin-bottom: 1rem; 39 | } 40 | 41 | .slider__legend li { 42 | display: flex; 43 | margin-bottom: 1rem; 44 | } 45 | 46 | .slider__legend li span { 47 | display: inline-block; 48 | } 49 | 50 | .slider__legend li span:first-child { 51 | height: 20px; 52 | width: 20px; 53 | margin-bottom: -2px; 54 | } 55 | 56 | .slider__legend li span:nth-child(2) { 57 | margin: 0 0.5rem; 58 | } 59 | 60 | .slider__legend li span:last-child { 61 | font-size: 1.25rem; 62 | font-weight: 600; 63 | font-variant-numeric: tabular-nums lining-nums; 64 | min-width: 60px; 65 | } 66 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | class Slider { 2 | 3 | /** 4 | * @constructor 5 | * 6 | * @param {string} DOM selector 7 | * @param {array} sliders 8 | */ 9 | constructor({ DOMselector, sliders }) { 10 | this.DOMselector = DOMselector; 11 | this.container = document.querySelector(this.DOMselector); // Slider container 12 | this.sliderWidth = 400; // Slider width 13 | this.sliderHeight = 400; // Slider length 14 | this.cx = this.sliderWidth / 2; // Slider center X coordinate 15 | this.cy = this.sliderHeight / 2; // Slider center Y coordinate 16 | this.tau = 2 * Math.PI; // Tau constant 17 | this.sliders = sliders; // Sliders array with opts for each slider 18 | this.arcFractionSpacing = 0.85; // Spacing between arc fractions 19 | this.arcFractionLength = 10; // Arc fraction length 20 | this.arcFractionThickness = 25; // Arc fraction thickness 21 | this.arcBgFractionColor = '#D8D8D8'; // Arc fraction color for background slider 22 | this.handleFillColor = '#fff'; // Slider handle fill color 23 | this.handleStrokeColor = '#888888'; // Slider handle stroke color 24 | this.handleStrokeThickness = 3; // Slider handle stroke thickness 25 | this.mouseDown = false; // Is mouse down 26 | this.activeSlider = null; // Stores active (selected) slider 27 | } 28 | 29 | /** 30 | * Draw sliders on init 31 | * 32 | */ 33 | draw() { 34 | 35 | // Create legend UI 36 | this.createLegendUI(); 37 | 38 | // Create and append SVG holder 39 | const svgContainer = document.createElement('div'); 40 | svgContainer.classList.add('slider__data'); 41 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 42 | svg.setAttribute('height', this.sliderWidth); 43 | svg.setAttribute('width', this.sliderHeight); 44 | svgContainer.appendChild(svg); 45 | this.container.appendChild(svgContainer); 46 | 47 | // Draw sliders 48 | this.sliders.forEach((slider, index) => this.drawSingleSliderOnInit(svg, slider, index)); 49 | 50 | // Event listeners 51 | svgContainer.addEventListener('mousedown', this.mouseTouchStart.bind(this), false); 52 | svgContainer.addEventListener('touchstart', this.mouseTouchStart.bind(this), false); 53 | svgContainer.addEventListener('mousemove', this.mouseTouchMove.bind(this), false); 54 | svgContainer.addEventListener('touchmove', this.mouseTouchMove.bind(this), false); 55 | window.addEventListener('mouseup', this.mouseTouchEnd.bind(this), false); 56 | window.addEventListener('touchend', this.mouseTouchEnd.bind(this), false); 57 | } 58 | 59 | /** 60 | * Draw single slider on init 61 | * 62 | * @param {object} svg 63 | * @param {object} slider 64 | * @param {number} index 65 | */ 66 | drawSingleSliderOnInit(svg, slider, index) { 67 | 68 | // Default slider opts, if none are set 69 | slider.radius = slider.radius ?? 50; 70 | slider.min = slider.min ?? 0; 71 | slider.max = slider.max ?? 1000; 72 | slider.step = slider.step ?? 50; 73 | slider.initialValue = slider.initialValue ?? 0; 74 | slider.color = slider.color ?? '#FF5733'; 75 | 76 | // Calculate slider circumference 77 | const circumference = slider.radius * this.tau; 78 | 79 | // Calculate initial angle 80 | const initialAngle = Math.floor( ( slider.initialValue / (slider.max - slider.min) ) * 360 ); 81 | 82 | // Calculate spacing between arc fractions 83 | const arcFractionSpacing = this.calculateSpacingBetweenArcFractions(circumference, this.arcFractionLength, this.arcFractionSpacing); 84 | 85 | // Create a single slider group - holds all paths and handle 86 | const sliderGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 87 | sliderGroup.setAttribute('class', 'sliderSingle'); 88 | sliderGroup.setAttribute('data-slider', index); 89 | sliderGroup.setAttribute('transform', 'rotate(-90,' + this.cx + ',' + this.cy + ')'); 90 | sliderGroup.setAttribute('rad', slider.radius); 91 | svg.appendChild(sliderGroup); 92 | 93 | // Draw background arc path 94 | this.drawArcPath(this.arcBgFractionColor, slider.radius, 360, arcFractionSpacing, 'bg', sliderGroup); 95 | 96 | // Draw active arc path 97 | this.drawArcPath(slider.color, slider.radius, initialAngle, arcFractionSpacing, 'active', sliderGroup); 98 | 99 | // Draw handle 100 | this.drawHandle(slider, initialAngle, sliderGroup); 101 | } 102 | 103 | /** 104 | * Output arch path 105 | * 106 | * @param {number} cx 107 | * @param {number} cy 108 | * @param {string} color 109 | * @param {number} angle 110 | * @param {number} singleSpacing 111 | * @param {string} type 112 | */ 113 | drawArcPath( color, radius, angle, singleSpacing, type, group ) { 114 | 115 | // Slider path class 116 | const pathClass = (type === 'active') ? 'sliderSinglePathActive' : 'sliderSinglePath'; 117 | 118 | // Create svg path 119 | const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 120 | path.classList.add(pathClass); 121 | path.setAttribute('d', this.describeArc(this.cx, this.cy, radius, 0, angle)); 122 | path.style.stroke = color; 123 | path.style.strokeWidth = this.arcFractionThickness; 124 | path.style.fill = 'none'; 125 | path.setAttribute('stroke-dasharray', this.arcFractionLength + ' ' + singleSpacing); 126 | group.appendChild(path); 127 | } 128 | 129 | /** 130 | * Draw handle for single slider 131 | * 132 | * @param {object} slider 133 | * @param {number} initialAngle 134 | * @param {group} group 135 | */ 136 | drawHandle(slider, initialAngle, group) { 137 | 138 | // Calculate handle center 139 | const handleCenter = this.calculateHandleCenter(initialAngle * this.tau / 360, slider.radius); 140 | 141 | // Draw handle 142 | const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 143 | handle.setAttribute('class', 'sliderHandle'); 144 | handle.setAttribute('cx', handleCenter.x); 145 | handle.setAttribute('cy', handleCenter.y); 146 | handle.setAttribute('r', this.arcFractionThickness / 2); 147 | handle.style.stroke = this.handleStrokeColor; 148 | handle.style.strokeWidth = this.handleStrokeThickness; 149 | handle.style.fill = this.handleFillColor; 150 | group.appendChild(handle); 151 | } 152 | 153 | /** 154 | * Create legend UI on init 155 | * 156 | */ 157 | createLegendUI() { 158 | 159 | // Create legend 160 | const display = document.createElement('ul'); 161 | display.classList.add('slider__legend'); 162 | 163 | // Legend heading 164 | const heading = document.createElement('h2'); 165 | heading.innerText = 'Legend'; 166 | display.appendChild(heading); 167 | 168 | // Legend data for all sliders 169 | this.sliders.forEach((slider, index) => { 170 | const li = document.createElement('li'); 171 | li.setAttribute('data-slider', index); 172 | const firstSpan = document.createElement('span'); 173 | firstSpan.style.backgroundColor = slider.color ?? '#FF5733'; 174 | firstSpan.classList.add('colorSquare'); 175 | const secondSpan = document.createElement('span'); 176 | secondSpan.innerText = slider.displayName ?? 'Unnamed value'; 177 | const thirdSpan = document.createElement('span'); 178 | thirdSpan.innerText = slider.initialValue ?? 0; 179 | thirdSpan.classList.add('sliderValue'); 180 | li.appendChild(firstSpan); 181 | li.appendChild(secondSpan); 182 | li.appendChild(thirdSpan); 183 | display.appendChild(li); 184 | }); 185 | 186 | // Append to DOM 187 | this.container.appendChild(display); 188 | } 189 | 190 | /** 191 | * Redraw active slider 192 | * 193 | * @param {element} activeSlider 194 | * @param {obj} rmc 195 | */ 196 | redrawActiveSlider(rmc) { 197 | const activePath = this.activeSlider.querySelector('.sliderSinglePathActive'); 198 | const radius = +this.activeSlider.getAttribute('rad'); 199 | const currentAngle = this.calculateMouseAngle(rmc) * 0.999; 200 | 201 | // Redraw active path 202 | activePath.setAttribute('d', this.describeArc(this.cx, this.cy, radius, 0, this.radiansToDegrees(currentAngle))); 203 | 204 | // Redraw handle 205 | const handle = this.activeSlider.querySelector('.sliderHandle'); 206 | const handleCenter = this.calculateHandleCenter(currentAngle, radius); 207 | handle.setAttribute('cx', handleCenter.x); 208 | handle.setAttribute('cy', handleCenter.y); 209 | 210 | // Update legend 211 | this.updateLegendUI(currentAngle); 212 | } 213 | 214 | /** 215 | * Update legend UI 216 | * 217 | * @param {number} currentAngle 218 | */ 219 | updateLegendUI(currentAngle) { 220 | const targetSlider = this.activeSlider.getAttribute('data-slider'); 221 | const targetLegend = document.querySelector(`li[data-slider="${targetSlider}"] .sliderValue`); 222 | const currentSlider = this.sliders[targetSlider]; 223 | const currentSliderRange = currentSlider.max - currentSlider.min; 224 | let currentValue = currentAngle / this.tau * currentSliderRange; 225 | const numOfSteps = Math.round(currentValue / currentSlider.step); 226 | currentValue = currentSlider.min + numOfSteps * currentSlider.step; 227 | targetLegend.innerText = currentValue; 228 | } 229 | 230 | /** 231 | * Mouse down / Touch start event 232 | * 233 | * @param {object} e 234 | */ 235 | mouseTouchStart(e) { 236 | if (this.mouseDown) return; 237 | this.mouseDown = true; 238 | const rmc = this.getRelativeMouseOrTouchCoordinates(e); 239 | this.findClosestSlider(rmc); 240 | this.redrawActiveSlider(rmc); 241 | } 242 | 243 | /** 244 | * Mouse move / touch move event 245 | * 246 | * @param {object} e 247 | */ 248 | mouseTouchMove(e) { 249 | if (!this.mouseDown) return; 250 | e.preventDefault(); 251 | const rmc = this.getRelativeMouseOrTouchCoordinates(e); 252 | this.redrawActiveSlider(rmc); 253 | } 254 | 255 | /** 256 | * Mouse move / touch move event 257 | * Deactivate slider 258 | * 259 | */ 260 | mouseTouchEnd() { 261 | if (!this.mouseDown) return; 262 | this.mouseDown = false; 263 | this.activeSlider = null; 264 | } 265 | 266 | /** 267 | * Calculate number of arc fractions and space between them 268 | * 269 | * @param {number} circumference 270 | * @param {number} arcBgFractionLength 271 | * @param {number} arcBgFractionBetweenSpacing 272 | * 273 | * @returns {number} arcFractionSpacing 274 | */ 275 | calculateSpacingBetweenArcFractions(circumference, arcBgFractionLength, arcBgFractionBetweenSpacing) { 276 | const numFractions = Math.floor((circumference / arcBgFractionLength) * arcBgFractionBetweenSpacing); 277 | const totalSpacing = circumference - numFractions * arcBgFractionLength; 278 | return totalSpacing / numFractions; 279 | } 280 | 281 | /** 282 | * Helper functiom - describe arc 283 | * 284 | * @param {number} x 285 | * @param {number} y 286 | * @param {number} radius 287 | * @param {number} startAngle 288 | * @param {number} endAngle 289 | * 290 | * @returns {string} path 291 | */ 292 | describeArc (x, y, radius, startAngle, endAngle) { 293 | let path, 294 | endAngleOriginal = endAngle, 295 | start, 296 | end, 297 | arcSweep; 298 | 299 | if(endAngleOriginal - startAngle === 360) 300 | { 301 | endAngle = 359; 302 | } 303 | 304 | start = this.polarToCartesian(x, y, radius, endAngle); 305 | end = this.polarToCartesian(x, y, radius, startAngle); 306 | arcSweep = endAngle - startAngle <= 180 ? '0' : '1'; 307 | 308 | path = [ 309 | 'M', start.x, start.y, 310 | 'A', radius, radius, 0, arcSweep, 0, end.x, end.y 311 | ]; 312 | 313 | if (endAngleOriginal - startAngle === 360) 314 | { 315 | path.push('z'); 316 | } 317 | 318 | return path.join(' '); 319 | } 320 | 321 | /** 322 | * Helper function - polar to cartesian transformation 323 | * 324 | * @param {number} centerX 325 | * @param {number} centerY 326 | * @param {number} radius 327 | * @param {number} angleInDegrees 328 | * 329 | * @returns {object} coords 330 | */ 331 | polarToCartesian (centerX, centerY, radius, angleInDegrees) { 332 | const angleInRadians = angleInDegrees * Math.PI / 180; 333 | const x = centerX + (radius * Math.cos(angleInRadians)); 334 | const y = centerY + (radius * Math.sin(angleInRadians)); 335 | return { x, y }; 336 | } 337 | 338 | /** 339 | * Helper function - calculate handle center 340 | * 341 | * @param {number} angle 342 | * @param {number} radius 343 | * 344 | * @returns {object} coords 345 | */ 346 | calculateHandleCenter (angle, radius) { 347 | const x = this.cx + Math.cos(angle) * radius; 348 | const y = this.cy + Math.sin(angle) * radius; 349 | return { x, y }; 350 | } 351 | 352 | /** 353 | * Get mouse/touch coordinates relative to the top and left of the container 354 | * 355 | * @param {object} e 356 | * 357 | * @returns {object} coords 358 | */ 359 | getRelativeMouseOrTouchCoordinates (e) { 360 | const containerRect = document.querySelector('.slider__data').getBoundingClientRect(); 361 | let x, 362 | y, 363 | clientPosX, 364 | clientPosY; 365 | 366 | // Touch Event triggered 367 | if (window.TouchEvent && e instanceof TouchEvent) 368 | { 369 | clientPosX = e.touches[0].pageX; 370 | clientPosY = e.touches[0].pageY; 371 | } 372 | // Mouse Event Triggered 373 | else 374 | { 375 | clientPosX = e.clientX; 376 | clientPosY = e.clientY; 377 | } 378 | 379 | // Get Relative Position 380 | x = clientPosX - containerRect.left; 381 | y = clientPosY - containerRect.top; 382 | 383 | return { x, y }; 384 | } 385 | 386 | /** 387 | * Calculate mouse angle in radians 388 | * 389 | * @param {object} rmc 390 | * 391 | * @returns {number} angle 392 | */ 393 | calculateMouseAngle(rmc) { 394 | const angle = Math.atan2(rmc.y - this.cy, rmc.x - this.cx); 395 | 396 | if (angle > - this.tau / 2 && angle < - this.tau / 4) 397 | { 398 | return angle + this.tau * 1.25; 399 | } 400 | else 401 | { 402 | return angle + this.tau * 0.25; 403 | } 404 | } 405 | 406 | /** 407 | * Helper function - transform radians to degrees 408 | * 409 | * @param {number} angle 410 | * 411 | * @returns {number} angle 412 | */ 413 | radiansToDegrees(angle) { 414 | return angle / (Math.PI / 180); 415 | } 416 | 417 | /** 418 | * Find closest slider to mouse pointer 419 | * Activate the slider 420 | * 421 | * @param {object} rmc 422 | */ 423 | findClosestSlider(rmc) { 424 | const mouseDistanceFromCenter = Math.hypot(rmc.x - this.cx, rmc.y - this.cy); 425 | const container = document.querySelector('.slider__data'); 426 | const sliderGroups = Array.from(container.querySelectorAll('g')); 427 | 428 | // Get distances from client coordinates to each slider 429 | const distances = sliderGroups.map(slider => { 430 | const rad = parseInt(slider.getAttribute('rad')); 431 | return Math.min( Math.abs(mouseDistanceFromCenter - rad) ); 432 | }); 433 | 434 | // Find closest slider 435 | const closestSliderIndex = distances.indexOf(Math.min(...distances)); 436 | this.activeSlider = sliderGroups[closestSliderIndex]; 437 | } 438 | } 439 | 440 | 441 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 |