├── app.css
├── README.md
├── index.html
└── app.js
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | JS Round Range Slider App
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
JS Round Range Slider App
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------