Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mattdanielbrown/e33d724df6d52e90189da89246551bb3 to your computer and use it in GitHub Desktop.

Select an option

Save mattdanielbrown/e33d724df6d52e90189da89246551bb3 to your computer and use it in GitHub Desktop.
Custom Dial (Range) Input Control
<main>
<section>
<form>
<fieldset>
<legend>Custom Dial (Range) Input Control</legend>
<!-- <label for="range" class="visually-hidden">Styled range input</label> -->
<label for="range">Room Temperature</label>
<input type="range" id="range" min="0" max="100" step="1" class="visually-hidden" value="75" />
<output id="output" for="range" role="status" /></output>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" focusable="false" aria-hidden="true">
<path id="track" fill="none" stroke="ButtonFace" stroke-linecap="round"></path>
<!-- @link https://darchevillepatrick.info/svg/svg_arcs.htm -->
<!-- @link http://xahlee.info/js/svg_circle_arc.html -->
<path id="progress" fill="none" stroke="LinkText" stroke-linecap="round" />
<!-- @link https://codepen.io/alxmtr/pen/jKQKvO -->
<circle id="thumb" stroke="#fff" fill="ActiveText" />
<!-- <circle id="thumb-outline" stroke="#5000ff" fill="ActiveText" /> -->
</svg>
</fieldset>
</form>
</section>
</main>
<footer>
<p>Copyright &copy; 2022. All Rights Reserved.</p>
</footer>
// @todo Add a switch to get back to standard range input?
// Get elements
const form = document.getElementsByTagName('form')[0];
const range = document.querySelector('[type="range"]');
const output = document.querySelector('output');
const svg = document.getElementsByTagName('svg')[0];
const element = document.getElementById('progress');
const track = document.getElementById('track');
const thumb = document.getElementById('thumb');
// Get attributes
const max = parseFloat(range.getAttribute('max'));
const min = parseFloat(range.getAttribute('min'));
const stepAttr = parseFloat(range.getAttribute('step'));
// Set breakpoint
const breakpoint = window.matchMedia('(max-width: 960px)');
// Set useful constants
const scale = (360 - 90) / max;
const steps = ((max - min) / stepAttr);
const { cos } = Math;
const { sin } = Math;
const π = Math.PI;
// Set flags
let isMoving = false;
let previousValue = 50;
let previousCoordinates = {x: 0, y: 0};
// Get circle depending on media query
const getCircle = (mq) => {
let circle = {
center: [230, 230],
radius: [208, 208],
angle: ((max / 2) * scale) / 180 * π
}
if (mq.matches) {
circle = {
center: [115, 115],
radius: [104, 104],
angle: ((max / 2) * scale) / 180 * π
}
}
return circle;
}
// Get all snappable points
const getPoints = () => {
const points = [];
let progressLength = 0;
const trackLength = track.getTotalLength();
const step = trackLength / steps; // Use max instead steps if step="1"
while (progressLength < trackLength + 1) {
const DOMPoint = track.getPointAtLength(progressLength);
points.push({x: DOMPoint.x.toFixed(3), y: DOMPoint.y.toFixed(3), d: progressLength});
progressLength += step;
}
return points;
}
// Draw an arc
// @author Xah Lee © 2020
// @link http://xahlee.info/js/svg_circle_arc.html
const _matrixTimes = (( [[a,b], [c,d]], [x,y]) => [ a * x + b * y, c * x + d * y]);
const _rotateMatrix = (x => [[cos(x),-sin(x)], [sin(x), cos(x)]]);
const _vecAdd = (([a1, a2], [b1, b2]) => [a1 + b1, a2 + b2]);
const getPath = ([cx,cy],[rx,ry], [t1, Δ], φ ) => {
// eslint-disable-next-line no-param-reassign
Δ %= (2*π);
const rotMatrix = _rotateMatrix (φ);
const [sX, sY] = (_vecAdd(_matrixTimes(rotMatrix, [rx * cos(t1), ry * sin(t1)]), [cx,cy]));
const [eX, eY] = (_vecAdd(_matrixTimes(rotMatrix, [rx * cos(t1+Δ), ry * sin(t1+Δ)]), [cx,cy]));
const fA = (Δ > π) ? 1 : 0;
const fS = (Δ > 0) ? 1 : 0;
return (`M ${sX} ${sY} A ${[rx , ry , φ / (2*π) *360, fA, fS, eX, eY].join(',')}`);
};
const setPath = (value) => {
const circle = getCircle(breakpoint);
range.style.setProperty('--value', `${value}%`);
output.value = value;
// Update path based on range's initial value
const sweep = (value * scale) / 180 * π;
const path = getPath(circle.center, circle.radius, [circle.angle, sweep], 0);
element.setAttribute('d', path);
}
const setState = (value, x, y) => {
previousValue = value;
previousCoordinates = {x: x, y: y};
}
const setThumb = () => {
const path = element.getAttribute('d');
const values = path.split(',');
const pos = {
x: values[values.length - 2],
y: values[values.length - 1]
};
thumb.setAttribute('cx', pos.x);
thumb.setAttribute('cy', pos.y);
}
// Find closest snappable point
// @link https://www.calculatorsoup.com/calculators/geometry-plane/distance-two-points.php
const getClosestPoint = (x, y) => {
const distances = [];
const points = getPoints();
points.forEach((point, index) => {
const diffX = x - point.x;
const diffY = y - point.y;
const distance = Math.sqrt((diffX * diffX) + (diffY * diffY)).toFixed(3);
distances.push([index, parseFloat(distance)]);
});
distances.sort((a, b) => a[1] - b[1]);
return points[distances[0][0]];
}
// Show something <3
const render = (e) => {
e.preventDefault();
if(isMoving === true) {
const rect = svg.getBoundingClientRect();
const trackLength = track.getTotalLength();
// eslint-disable-next-line no-param-reassign,prefer-destructuring
if (e.touches) { e = e.touches[0]; }
const pos = {
x: (e.clientX - rect.left).toFixed(3),
y: (e.clientY - rect.top).toFixed(3)
};
const target = getClosestPoint(pos.x, pos.y);
const covered = Math.round(target.d * max / trackLength);
thumb.setAttribute('cx', target.x);
thumb.setAttribute('cy', target.y);
setPath(covered);
range.value = covered;
if ('click' === e.type) {
setState(covered, target.x, target.y);
}
}
};
// Redraw SVG depending on breakpoint
// @todo Use .style.getProperty ?
const setSVG = () => {
const circle = getCircle(breakpoint);
const size = circle.center[0] * 2;
svg.setAttribute('width', size);
svg.setAttribute('height', size);
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
thumb.setAttribute('cx', size / 2);
if (!breakpoint.matches) {
track.setAttribute('d', `M 84 377 A ${circle.radius},0,1,1,377,377`);
track.setAttribute('stroke-width', '12');
element.setAttribute('stroke-width', '12');
thumb.setAttribute('stroke-width', '8');
thumb.setAttribute('r', '14');
} else {
track.setAttribute('d', `M 42 188 A ${circle.radius},0,1,1,188,188`);
track.setAttribute('stroke-width', '8');
element.setAttribute('stroke-width', '8');
thumb.setAttribute('stroke-width', '6');
thumb.setAttribute('r', '10');
}
setPath(range.value);
setThumb();
};
const dragEnd = () => {
isMoving = false;
svg.classList.remove('moving');
range.focus({preventScroll: true});
range.classList.add('has-focus');
}
const dragStart = () => {
isMoving = true;
svg.classList.add('moving');
}
range.addEventListener('input', () => {
range.classList.remove('has-focus');
setPath(range.value);
setThumb();
setState(range.value, thumb.getAttribute('cx'), thumb.getAttribute('cy'));
});
document.addEventListener('DOMContentLoaded', () => {
setSVG();
breakpoint.addEventListener('change', setSVG);
});
svg.addEventListener('click', (e) => {
dragStart();
render(e);
dragEnd();
});
form.addEventListener('pointerdown', () => {
dragStart();
setState(range.value, thumb.getAttribute('cx'), thumb.getAttribute('cy'));
form.addEventListener('pointermove', (e) => render(e));
form.addEventListener('pointerup', () => {
setState(range.value, thumb.getAttribute('cx'), thumb.getAttribute('cy'));
});
form.addEventListener('pointerleave', () => {
range.value = previousValue;
setPath(previousValue);
thumb.setAttribute('cx', previousCoordinates.x);
thumb.setAttribute('cy', previousCoordinates.y);
});
});
window.addEventListener('pointerup', dragEnd);
//// @note Handle reduced motion
//// @author Kitty Giraudel
//// @link https://kittygiraudel.com/2018/03/19/implementing-a-reduced-motion-mode/
:root {
--duration: 1;
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration: 0;
}
}
form {
margin: 2rem;
padding: 3rem;
position: relative;
width: fit-content;
}
label {
display: block;
margin-bottom: 1rem;
display: block;
position: relative;
}
// @note Using system color keywords to handle forced contrasts
//// @note Thanks Adrian Roselli
//// @link https://adrianroselli.com/2021/02/whcm-and-system-colors.html
output {
// position: absolute;
// position: above;
position: absolute;
width: 10rem;
height: 10rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
justify-content: center;
margin: auto;
// left: 4.35em;
// top: 8rem;
top: 2.5rem;
left: 9rem;
// transform: translate3d(-50%, -50%, 0);
transform: translate3d(32%, 20%, 0);
font-weight: 300;
font-size: 4rem;
// line-height: 10rem;
text-align: center;
color: #6000ff;
background-color: Canvas;
border: 1px solid ButtonFace;
border-radius: 50%;
// opacity: .95;
text-shadow: 0 2px 6px rgba(blue, 0.5);
box-shadow: 0 0.5rem 1rem -1rem rgba(0, 0, 0, 0.32),
0 0.5rem 1rem 0 rgba(0, 0, 0, 0.1);
}
@media (min-width: 960px) {
output {
width: 20rem;
height: 20rem;
left: 17.25rem;
top: 17rem;
font-size: 8rem;
line-height: 20rem;
transform: translate3d(-50%, -50%, 0);
}
}
//
// Based on Bootstrap 5, improved
//// @link https://github.com/twbs/bootstrap/blob/main/scss/forms/_form-range.scss
//// @note If you need to support Edge < 18 or IE11, check Bootstrap v4:
//// @link https://github.com/twbs/bootstrap/blob/v4-dev/scss/_custom-forms.scss#L379
// @author Gaël Poupard
//
[type="range"] {
width: 20rem;
padding: 0;
background-color: transparent;
appearance: none;
&:focus {
outline: 0;
}
&::-moz-focus-outer {
border: 0;
}
// Enhancements
&:focus-visible {
&::-webkit-slider-thumb {
box-shadow: 0 0 0 0.5rem LinkText;
}
&::-moz-range-thumb {
box-shadow: 0 0 0 0.5rem LinkText;
}
}
&::-webkit-slider-thumb {
width: 2rem;
height: 2rem;
margin-top: -0.5rem;
background-color: ActiveText;
background-color: #6000ff !important;
border: 0.5rem solid Canvas;
border-color: #6000ff !important;
border-radius: 1rem;
transition: background-color calc(var(--duration) * 0.15s) ease-in-out,
border-color calc(var(--duration) * 0.15s) ease-in-out,
box-shadow calc(var(--duration) * 0.15s) ease-in-out;
appearance: none;
// Enhancements
box-sizing: content-box;
cursor: grab;
filter:
drop-shadow(0 0 0.25rem rgba(0, 0, 0, 0.2)),
drop-shadow(0 0 2rem rgba(0, 0, 0, 0.8));
&:active {
cursor: grabbing;
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 2rem;
color: transparent;
cursor: pointer;
background-color: ButtonFace;
border-color: transparent;
border-radius: 1rem;
// Enhancement
background-image: linear-gradient(
to right,
LinkText var(--value, 0%),
ButtonFace var(--value, 0%)
);
}
&::-moz-range-thumb {
width: 1rem;
height: 1rem;
background-color: ActiveText;
border: 0.25rem solid Canvas;
border-radius: 1rem;
transition: background-color calc(var(--duration) * 0.15s) ease-in-out,
border-color calc(var(--duration) * 0.15s) ease-in-out,
box-shadow calc(var(--duration) * 0.15s) ease-in-out;
appearance: none;
// Enhancements
cursor: grab;
filter: drop-shadow(0 0 0.25rem rgba(0, 0, 0, 0.2));
&:active {
cursor: grabbing;
}
}
&::-moz-range-track {
width: 100%;
height: 0.5rem;
color: transparent;
cursor: pointer;
background-color: ButtonFace;
border-color: transparent;
border-radius: 1rem;
}
// Enhancements
&::-moz-range-progress {
height: 0.5rem;
background-color: LinkText;
border-radius: 1rem;
}
@media (prefers-reduced-motion: reduce) {
&::-webkit-slider-thumb {
transition: none;
}
&::-moz-range-thumb {
transition: none;
}
}
&:disabled {
pointer-events: none;
&::-webkit-slider-thumb {
background-color: GrayText;
}
&::-moz-range-thumb {
background-color: GrayText;
}
}
}
// Bootstrap v5
// @link https://github.com/twbs/bootstrap/blob/main/scss/mixins/_visually-hidden.scss
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
// SVG
svg {
overflow: visible !important;
}
[id="thumb"] {
cursor: grab;
filter: drop-shadow(0 0 0.25rem rgba(0, 0, 0, 0.125));
outline-offset: 1rem;
transition: outline-offset calc(var(--duration) * 0.3s) ease-in-out;
// transition: all calc(var(--duration) * 0.3s) ease-in-out;
&:active {
cursor: grabbing;
// opacity: 0.6;
}
// @todo Try to use box-shadow?
[type="range"]:focus-visible ~ svg & {
border-radius: 3rem;
outline: 0.35rem solid LinkText;
outline-color: rgba(#6000ff, 0.125) !important;
outline-offset: 0.125rem;
// text-shadow: 0 0 8px rgba(#6000ff, 0.8);
filter: drop-shadow(0 0 2px rgba(#6000ff,0.75));
transition: all .15s ease;
border: 8px solid #6000ff !important;
}
}
.moving {
cursor: grabbing;
}
/* Import webfont files for the Inter font-family from Google Fonts */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap");
/* Fontstacks */
$fontstack--fallback: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
$fontstack--inter: "Inter";
$fontstack: $fontstack--inter, $fontstack--fallback;
%container {
width: 92%;
max-width: 768px;
max-width: 65ch;
margin: 0 auto;
}
.container,
section,
article,
hgroup {
@extend %container;
}
html,body,p,
h1,h2,h3,h4,h5,h6,
form,input,range,label,
button,a,output {
font-family: $fontstack;
}
html,body {
margin: 0 !important;
padding: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
height: 100% !important;
min-height: 100vh !important;
background-color: ghostwhite;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
}
main {
background-color: ghostwhite;
flex-grow: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
justify-content: center;
}
h1,h2,h3,h4,h5,h6 {
line-height: 1 !important;
margin: 0 auto 1rem;
font-weight: 600;
}
body>header:first-of-type,
body>footer:last-of-type {
background-color: darken(ghostwhite, 2%);
width: 100vw;
padding: 2em 0;
}
body>footer:last-of-type {
text-align: center;
p {
line-height: 1;
text-align: center;
margin: 0 auto;
padding: 0;
opacity: 0.5;
font-weight: 500;
vertical-align: middle;
font-size: small;
}
}
form {
width: 100%;
display: block;
position: relative;
text-align: center;
}
fieldset {
all: unset;
display: block;
width: 100%;
position: relative;
text-align: center;
padding: 0;
}
legend {
font-weight: 600;
font-size: large;
line-height: 2;
// text-align: left;
width: 100%;
display: block;
}
// #thumb-outine {
// stroke-width: 4px;
// width: 2em;
// transform: scale(2,2);
// }
@media screen and (max-width: 719px) {
[type="range"],output {
// left: 25% !important;
// right: 20% !important;
left: unset;
right: unset;
// right: 0 !important;
// transform: translate(0,0) !important;
// transform: translate(45%,20%);
transform: unset;
text-align: center;
margin-left: auto;
margin-right: auto;
margin-top: 2rem;
}
form,fieldset,output,[type="range"] {
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
justify-content: center;
position: absolute;
}
form {
display: block;
width: 90%;
margin: auto;
text-align: center;
flex-grow: 1;
height: 100% !important;
margin-top: 0 !important;
top: 10% !important;
padding-top: 0 !important;
fieldset {
margin: 0 !important;
padding: 0 !important;
text-align: center;
overflow: hidden;
max-width: 100% !important;
width: 90% !important;
}
}
}
// * {
// outline: red solid 1px !important;
// }
<link href="https://cdnjs.cloudflare.com/ajax/libs/sanitize.css/2.0.0/sanitize.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/modern-normalize/1.1.0/modern-normalize.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/mobi.css/3.1.1/mobi.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/inter-ui/3.19.3/inter.css" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment