A Pen by Matt Daniel Brown on CodePen.
Created
August 1, 2022 13:33
-
-
Save mattdanielbrown/e33d724df6d52e90189da89246551bb3 to your computer and use it in GitHub Desktop.
Custom Dial (Range) Input Control
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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 © 2022. All Rights Reserved.</p> | |
| </footer> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // @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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //// @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; | |
| // } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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