Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save dromer/13ab8abef94303ac503a6d74e8ae19ef to your computer and use it in GitHub Desktop.
Save dromer/13ab8abef94303ac503a6d74e8ae19ef to your computer and use it in GitHub Desktop.
Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer

Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer

Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer

A Pen by dromer on CodePen.

License.

<form class="knob-group" lang="en" novalidate spellcheck="false">
<legend data-key="space / return">Knob Group</legend><hr/>
<fieldset class="knob">
<input type="number" id="k" value="0.5" min="0" max="1" step="0.0078125" placeholder="-" autocomplete="off" required/>
<label for="k" accesskey="k" data-unit="db">K Knob</label>
</fieldset>
<fieldset class="knob">
<input type="number" id="l" value="6" min="0" max="12" step="0.1" placeholder="-" autocomplete="off" required/>
<label for="l" accesskey="l" data-unit="Amp">L Knob</label>
</fieldset>
<fieldset class="knob">
<input type="number" id="m" value="0" min="0" max="100" step="1" placeholder="-" autocomplete="off" required/>
<label for="m" accesskey="m" data-unit="cm">M Knob</label>
</fieldset>
</form>
<!--
<form class="knob-group" lang="en" novalidate spellcheck="false">
<legend data-key="space / return">Knobs Balance</legend><hr/>
<fieldset class="knob knob-balance">
<input type="number" id="o" value="-5" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/>
<label for="o" accesskey="o">O Knob</label>
<i data-min="-12" data-max="12" aria-hidden="true"></i>
</fieldset>
<fieldset class="knob knob-balance">
<input type="number" id="p" value="5" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/>
<label for="p" accesskey="p">P Knob</label>
<i data-min="-12" data-max="12" aria-hidden="true"></i>
</fieldset>
<fieldset class="knob knob-balance">
<input type="number" id="q" value="0" min="-12" max="12" step="0.1" placeholder="-" autocomplete="off" required/>
<label for="q" accesskey="q">Q Knob</label>
<i data-min="-12" data-max="12" aria-hidden="true"></i>
</fieldset>
</form>
<form class="knob-group" lang="en" novalidate spellcheck="false">
<legend data-key="space / return">Small Knobs</legend><hr/>
<fieldset class="knob knob-small">
<input type="number" id="r" value="8" min="0" max="12" step="0.2" placeholder="-" autocomplete="off" required/>
<label for="r" accesskey="r" data-unit="db">R Knob</label>
</fieldset>
<fieldset class="knob knob-small">
<input type="number" id="s" value="4" min="0" max="12" step="0.2" placeholder="-" autocomplete="off" required/>
<label for="s" accesskey="s" data-unit="cm">S Knob</label>
</fieldset>
<fieldset class="knob knob-small">
<input type="number" id="t" value="0" min="-10" max="10" step="0.2" placeholder="-" autocomplete="off" required/>
<label for="t" accesskey="t">T Knob</label>
<i data-min="-10" data-max="10" aria-hidden="true"></i>
</fieldset>
<fieldset class="knob knob-small">
<input type="number" id="u" value="0" min="-10" max="10" step="0.2" placeholder="-" autocomplete="off" required/>
<label for="u" accesskey="u">U Knob</label>
<i data-min="-10" data-max="10" aria-hidden="true"></i>
</fieldset>
</form> -->
<style>
/* Presentation */
html{
height: 100%;
font-size: 1rem;
}
body{
height: 100%;
display: flex;
flex-direction: column;
margin: auto;
justify-content: center;
align-items: center;
background-color: #111;
font-family: Arial, Helvetica, sans-serif;
color: #fff;
user-select: none;
touch-action: none;
}
form { margin: 0 0 1em 0; }
form:last-of-type { margin-bottom: 0; }
</style>
// Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer
// Created: 2020.10.11, 10:20h
; (function (W, D){
var ks = D.querySelectorAll('.knob input'),
keys = { left:37, right:39, add:107, sub:109, home:36 , end:35, space:32, return:13, esc:27 },
path = '<path d="M20,76 A 40 40 0 1 1 80 76"/>', // 184 svg units for full stroke
curY = 0, moving = false, hasPE = W.PointerEvent;
[].forEach.call(ks, function (k){ knob.call(k); });
function knob () {
var k = this, id = k.id || k.name,
fls = k.parentElement,
lbl = fls.querySelector('[for="'+id+'"]'),
min = k.min ? parseFloat(k.min) : 0,
max = k.max ? parseFloat(k.max) : 100,
dif = Math.abs(min) + Math.abs(max),
stp = k.step ? parseFloat(k.step) : dif/10,
val = k.value ? parseFloat(k.value) : dif/2,
ind = fls.querySelector('svg path:last-of-type'),
frm = k.form ? k.form : fls.parentElement,
lgd = k.form.querySelector('legend'),
bal = Math.abs(min)-Math.abs(max) === 0 || fls.className.match('knob-balance');
// Fix missin properties, svg indicator & decimal
// separator ( ',' to '.' -> ua lang dependant) ?
frm.lang = 'en'; k.value = val; k.step = stp;
k.setAttribute('autocomplete','off');
if(bal && !fls.className.match('knob-balance')) fls.className += ' knob-balance';
if(lbl) lbl.onclick = function(e){ e.preventDefault(); };
if(!ind) ind = svg();
if(lgd) {
lgd.setAttribute('tabindex', 0);
lgd.onclick = function() { toggleGroup(frm); };
lgd.onkeydown = legendkeys;
}
// Event listener
k.addEventListener('input', input, false);
k.onkeydown = knobkeys;
fls.ondblclick = dblclick;
fls.addEventListener('wheel', wheel, false); // No attribute, because IE
hasPE ? fls.onpointerdown = start : fls.onmousedown = start; // Overwrite
ind.onclick = click;
ind.previousElementSibling.onclick = click;
input();
// Private methods
function input () {
val = k.value.trim();
if(val > max) k.value = max;
else if(val < min) k.value = min;
else if(val === '') k.value = min;
var per = (k.value/dif)*100,
deg = 0;
if(bal) { // Balance number input
deg = per*1.32*2;
var len = Math.abs(per)*1.84;
var drr = per > 0 ? ('87 10 0 '+len+' '+(87-len)) : ((87-len)+' '+len+' 0 10 87');
ind.style.setProperty('stroke-dasharray', drr);
fls.style.setProperty('--knob-deg', deg);
}
else { // Normal number input
if (per >= 0 && per <= 100 && per != 50) deg = per*1.32*2 - 132;
ind.style.setProperty('stroke-dashoffset', -per*1.84 +'%');
fls.style.setProperty('--knob-deg', deg);
}
}
function click (e) {
if(k.disabled || k.readonly) return;
var b = this.parentElement.getBoundingClientRect(),
c = { x: b.width/2, y: b.height/2 },
p2 = { x: e.pageX - b.left, y: e.pageY - b.top },
p1 = { x: 0, y: b.height }; // stroke-width 8 of path ?
var rad = angle (p1, c, p2) ;
var deg = rad * (180/Math.PI);
if(p2.x > b.width/2 && deg < 180) deg = 360 - deg;
// console.log(parseInt(deg,10) +'°', (dif/270)*deg);
k.value = parseInt((dif/270)*deg);
k.dispatchEvent(new Event('input'));
}
function dblclick (e) {
if(k.disabled || k.readonly) return;
var cache = k.hasAttribute('data-cache');
if(cache) { k.value = k.getAttribute('data-cache'); k.removeAttribute('data-cache'); }
else { k.setAttribute('data-cache', k.value); k.value = bal ? 0 : k.defaultValue; }
k.dispatchEvent(new Event('input'));
}
function start (e) {
if(k.disabled || k.readonly) return;
moving = true; curY = e.pageY;
D.addEventListener(hasPE ? 'pointermove' : 'mousemove', move, false);
D.addEventListener(hasPE ? 'pointerup' : 'mouseup', end, false);
}
function move (e) {
if(e.pageY - curY !== 0) {
(e.pageY - curY) > 0 ? k.stepUp() : k.stepDown();
k.dispatchEvent(new Event('input'));
}
curY = e.pageY;
}
function end (e) {
moving = false; curY = 0;
D.removeEventListener(hasPE ? 'pointermove' : 'mousemove', move, false);
D.removeEventListener(hasPE ? 'pointerup' : 'mouseup', end, false);
k.select();
}
function wheel (e) {
var delta = e.deltaY;
if(delta !== 0) {
delta < 0 ? k.stepUp() : k.stepDown();
k.dispatchEvent(new Event('input'));
}
}
function knobkeys (e) {
if(this !== D.activeElement) return;
var c = e.keyCode ? e.keyCode : e.which;
if (c === keys.left) { k.stepDown(); }
else if (c === keys.right) { k.stepUp(); }
else if (c === keys.end) { k.value = min; }
else if (c === keys.home) { k.value = max; }
else if (c === keys.add) { k.stepUp(); }
else if (c === keys.sub) { k.stepDown(); }
else if (c === keys.esc && lgd) { lgd.focus(); }
k.dispatchEvent(new Event('input'));
}
function legendkeys (e) {
if(this !== D.activeElement) return;
var c = e.keyCode ? e.keyCode : e.which;
if(c === keys.space) toggleGroup(frm);
else if(c === keys.return) toggleGroup(frm);
};
function svg () {
var s = D.createElementNS('http://www.w3.org/2000/svg','svg');
s.setAttribute('viewBox','0 0 100 100'); s.setAttribute('aria-hidden', true);
s.innerHTML = path + path; fls.appendChild(s);
return s.querySelector('path:last-of-type');
}
function angle (p1, c, p2) { // Point 1, circle center point, point 2
var p1c = Math.sqrt(Math.pow(c.x-p1.x, 2)+ Math.pow(c.y-p1.y, 2));
var cp2 = Math.sqrt(Math.pow(c.x-p2.x, 2)+ Math.pow(c.y-p2.y, 2));
var p1p2 = Math.sqrt(Math.pow(p2.x-p1.x, 2)+ Math.pow(p2.y-p1.y, 2));
return Math.acos((cp2*cp2 + p1c*p1c - p1p2*p1p2)/(2*cp2*p1c)) ;
}
function toggleGroup (f) {
var s = f.hasAttribute('data-status') ? f.getAttribute('data-status') : false,
isD = s ? s.match('disabled') : false,
isR = s ? s.match('readonly') : false;
isD ? f.removeAttribute('data-status') : f.setAttribute('data-status', 'disabled');
[].forEach.call(frm.querySelectorAll('.knob input'), function (i){
if(isD) { i.removeAttribute('disabled'); i.required = true; i.draggable = true;}
else { i.disabled = true; i.removeAttribute('required'); i.removeAttribute('draggable'); }
if(isD) { i.removeAttribute('readonly'); i.required = true; }
else { i.setAttribute('readonly',''); i.removeAttribute('required'); }
});
}
}
})(window, document);
// Theming
$primary: magenta !default;
$secondary: cyan !default;
$white: #FFF !default;
$black: #000 !default;
$dark: #2c2d2f !default;
$gray: gray !default;
// Accessible Semantic SCSS / Vanilla Number Input Knob / Potentiometer
$knob-d: 4em !default;
$knob-c: gray !default;
$knob-spacing: .5em !default;
$knob-border-c: #181b1c !default;
$knob-border-w: .5em !default;
$knob-bg-c: $dark !default;
$knob-ind-c: #888 !default;
$knob-ind-focus-c: magenta !default;
$knob-label-c: #e4e8ea !default;
$knob-group-border-r: .2rem !default;
$knob-group-bg-c: $dark !default;
$knob-font: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace !default;
// Box model reset
*, *::before, *::after { box-sizing: border-box; }
// Light / dark mode (light is default)
[data-mode="dark"] {
.knob-group {
}
.knob {
}
}
// Knobs group container (i.e. a form)
.knob-group {
position: relative;
display: inline-block;
background-color: $knob-group-bg-c;
padding: 0 $knob-spacing;
border-radius: $knob-group-border-r;
font-family: $knob-font;
legend {
position: relative;
display: block;
width: auto;
line-height: 1.5;
text-align: left;
padding: $knob-spacing $knob-spacing $knob-spacing/2 $knob-spacing;
white-space: nowrap;
background-color: $knob-group-bg-c;
z-index: 2;
outline: 0;
&:focus {
&::before { box-shadow: 0 0 0 .2em rgba($knob-ind-focus-c, .25); }
}
&:focus-visible {
&::after { content: attr(data-key); }
}
&::before {
content: '';
position: relative;
display: inline-block;
height: $knob-spacing;
width: $knob-spacing;
float: left;
border-radius: 100%;
background-color: $knob-ind-focus-c;
cursor: pointer;
transition: background .2s linear;
margin: $knob-spacing ($knob-spacing*2) 0 (-$knob-spacing/2);
}
&::after {
position: absolute;
right: $knob-spacing/4; top: $knob-spacing*1.25;
font-size:.5em;
padding: .5em 1em;
background-color: $knob-border-c;
font-weight: 600;
}
}
hr {
position: absolute;
width: calc(100% - #{$knob-spacing*2});
display: inline-block;
margin: 0; padding: 0;
top: $knob-spacing*2.5; right: $knob-spacing;
border: 0;
border-top: 1px solid currentColor;
z-index: 1;
}
&[data-status="disabled"] {
// color: $knob-ind-c;
legend {
color: currentColor !important;
&::before { background-color: currentColor; }
&:focus::before { box-shadow: 0 0 12px 1px currentColor;}
}
.knob {
svg path:first-of-type { stroke: currentColor; }
}
}
}
// Knob container (i.e. a fieldset)
.knob {
--knob-deg: 0;
display: inline-block;
position: relative;
padding:0; margin:0;
padding-bottom: 2em;
width: $knob-d;
border: 0;
text-align: center;
touch-action: none;
font-size: 1rem; // Knob sizing
&.knob-small { font-size: .72rem; }
&.knob-balance {
hr, i {
position: absolute;
top: $knob-d/.65 - 1em; left: 0;
width: 100%;
border: 0;
font-size: .65em;
font-style: normal;
line-height: 2.5;
color: $knob-ind-c;
&::before, &::after { position: absolute; }
&::before { content: attr(data-min); left: 0; }
&::after { content: attr(data-max); right: 0; }
}
svg path {
stroke-dasharray: 87 10 87;
&:last-of-type { stroke-dashoffset: 0; }
}
}
// debug*, &, *::before, *::after { box-shadow: 0 0 0 1px rgba(255,255,255,.3); }
input {
appearance: textfield;
-moz-appearance: textfield; // if not autoprefixed
position: relative;
left: 0; top:0;
display: block;
width: $knob-d/.75; height: $knob-border-w*3;
font: inherit;
font-size: .75em;
line-height: 1;
color: currentColor;
text-align: center;
font-variant-numeric: tabular-nums lining-nums;
background-color: transparent;
border: 0;
margin: $knob-d/.75 + .5em 0 0 0; padding: 0;
outline: 0;
cursor: default;
z-index: 2;
caret-color: currentColor;
&:placeholder { opacity: 1; color: currentColor; }
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&::selection { color: currentColor; background-color: $knob-ind-focus-c; }
&[disabled], &[readonly] {
cursor: not-allowed;
& ~ *, & ~ *::before, & ~ *::after {
pointer-events: none;
}
&::selection { background-color: transparent; }
}
}
label {
position: absolute;
left: 0; top: 0;
display: inline-block;
width: 100%; height: 100%;
overflow: hidden;
padding-top: $knob-d + 2em;
font-size: 1em;
white-space: nowrap;
text-overflow: ellipsis;
z-index: 1;
pointer-events: none;
&::before, &::after {
position: absolute;
display: inline-block;
}
&::before {
content: '';
left: 50%; top: $knob-border-w;
width: $knob-d - $knob-border-w; height: $knob-d - $knob-border-w ;
border: $knob-border-w solid $knob-border-c;
border-radius: 100%;
background-color: transparent;
background: linear-gradient(to bottom, currentColor 0% 100%) no-repeat 50% 0%;
background-size: .2em 1em;
transform-origin: center center;
transform: translateX(-50%) rotate(0deg); // Fallback
transform: translateX(-50%) rotate(calc(1deg * var(--knob-deg)));
cursor: default;
// cursor: n-resize;
pointer-events: fill;
}
&::after {
content: attr(data-unit);
top: $knob-d/.65 - 1.1em; right: 0;
font-size: .65em;
line-height: 2.5;
color: $knob-ind-c;
}
}
svg {
position: absolute;
left: 50%; top: 0;
width: $knob-d + $knob-border-w; height: $knob-d + $knob-border-w ;
transform: translateX(-50%);
stroke-dasharray: 184 184;
fill: none;
stroke: currentColor;
z-index: 3;
path {
// stroke-width: $knob-border-w;
stroke-width: 5;
stroke-dashoffset: 0;
visibility: visible;
pointer-events: stroke;
transition: all .2s cubic-bezier(0, 0, 0.2, 1);
&:first-of-type { stroke: $knob-ind-focus-c; }
&:last-of-type { stroke: $knob-ind-c; stroke-dashoffset: -97; }
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment