Scrub Number
Drag sideways across a number to scrub it up and down, like the fields in design tools. Little arrows show which way it’s going, and pushing past the lowest or highest value stretches the number before it springs back. It can feed a CSS variable, so something else follows along live.
Size80px
Turn15°
Round16px
Hue270
How to use it
- Paste the snippet just before
</head>. - Wrap a number in an element with
data-scrub-number. - Set its limits with
data-minanddata-max, and listen forinput, or setdata-varto drive a CSS variable.
Options
Add any of these attributes to the same element.
data-min / data-max | The lowest and highest values. Default: no limit. |
data-step | How much each step changes it. Default 1. |
data-px | Pixels of drag per step. Default 4. |
data-decimals | Decimal places shown. Default: as many as the step has. |
data-var | A CSS variable to set, such as --size, on the closest [data-scrub-scope] (or else the parent). |
data-unit | Added to the variable’s value, such as px or deg. |
data-autoplay | A pretend finger scrubs it while in view. Numbers in the same scope take turns. A real drag pauses it. |
Hold Shift for steps ten times bigger, or Alt for ten times smaller. It’s a spinbutton, so the arrow keys, Page Up/Down, Home and End work with focus. It fires input while it changes and change when you let go, both with detail.value.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scrub Number: drag sideways across a number to scrub its value up and
// down, like the fields in design tools. Little arrows show which way it
// is going, and pushing past the lowest or highest value stretches the
// number a little before it springs back. It can feed a CSS variable, so
// something else on the page follows along live.
// Usage: <span data-scrub-number data-min="0" data-max="200">120</span>
// Optional: data-min, data-max, data-step (default 1), data-px (pixels of
// drag per step, default 4), data-decimals (default: as many as the step),
// data-var (a CSS variable to set, e.g. --size, on the closest
// [data-scrub-scope] or else the parent), data-unit (added to the
// variable's value, e.g. px), data-autoplay (a pretend finger scrubs it
// while in view; numbers in the same scope take turns; a real drag pauses it).
// Hold Shift for steps ten times bigger, Alt for ten times smaller.
// Keyboard: arrow keys, Page Up/Down, Home/End.
// Events: "input" while it changes and "change" when you let go, both with detail.value.
var DEFAULTS = { step: 1, px: 4, autoplay: 3200 };
var css =
'.scn{position:relative;display:inline-block;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none;' +
'font-variant-numeric:tabular-nums;border-radius:6px;padding:0 .3em;margin:0 -.05em;transition:background-color .2s,box-shadow .2s;outline:none}' +
'.scn:hover,.scn:focus-visible,.scn.scn-held{background:rgba(106,13,173,.1);box-shadow:inset 0 -2px 0 rgba(106,13,173,.55)}' +
'.scn-arrow{position:absolute;top:50%;width:0;height:0;margin-top:-4px;border:4px solid transparent;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s}' +
'.scn-arrow.scn-l{right:100%;border-right:5px solid currentColor;transform:translateX(3px)}' +
'.scn-arrow.scn-r{left:100%;border-left:5px solid currentColor;transform:translateX(-3px)}' +
'.scn:hover .scn-arrow,.scn:focus-visible .scn-arrow,.scn.scn-held .scn-arrow{opacity:.35;transform:none}' +
'.scn.scn-up .scn-r,.scn.scn-down .scn-l{opacity:.9}' +
'.scn-finger{position:absolute;left:50%;top:50%;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;' +
'background:rgba(106,13,173,.18);border:2px solid rgba(106,13,173,.55);pointer-events:none;opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function band(d, dim) { return (1 - 1 / (d * 0.55 / dim + 1)) * dim; }
function setup(el) {
var d = el.dataset;
var text = el.textContent.trim();
el.textContent = '';
var num = el.appendChild(document.createElement('span'));
num.style.display = 'inline-block';
var step = +d.step || DEFAULTS.step;
var px = +d.px || DEFAULTS.px;
var min = d.min !== undefined ? +d.min : -Infinity;
var max = d.max !== undefined ? +d.max : Infinity;
var dec = d.decimals !== undefined ? +d.decimals : ((String(step).split('.')[1] || '').length);
var thumb = !!el.closest('[aria-hidden="true"]');
var scope = el.closest('[data-scrub-scope]') || el.parentElement;
el.classList.add('scn');
['scn-l', 'scn-r'].forEach(function (c) {
var a = document.createElement('span');
a.className = 'scn-arrow ' + c;
a.setAttribute('aria-hidden', 'true');
el.appendChild(a);
});
if (!thumb) {
el.tabIndex = 0;
el.setAttribute('role', 'spinbutton');
if (isFinite(min)) el.setAttribute('aria-valuemin', min);
if (isFinite(max)) el.setAttribute('aria-valuemax', max);
}
var s = el._scn = { v: parseFloat(text) || 0, userAt: -1e9 };
var drag = null, back = null;
function show(v, quiet) {
var old = s.v;
s.v = v;
num.textContent = v.toFixed(dec);
if (!thumb) el.setAttribute('aria-valuenow', v.toFixed(dec));
if (d.var && scope) scope.style.setProperty(d.var, v.toFixed(dec) + (d.unit || ''));
if (!quiet && v !== old) {
el.classList.toggle('scn-up', v > old);
el.classList.toggle('scn-down', v < old);
el.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: v } }));
}
}
function clampV(v) { return Math.max(min, Math.min(max, Math.round(v / step) * step)); }
function grab(x) {
if (back) { back.pause(); back = null; }
drag = { x0: x, v0: s.v, mult: 1 };
el.classList.add('scn-held');
}
function moveTo(x, mult) {
if (!drag) return;
if (mult !== drag.mult) { drag.v0 = s.v; drag.x0 = x; drag.mult = mult; }
var raw = drag.v0 + (x - drag.x0) / px * step * mult;
var v = clampV(raw);
show(v);
// Past an end, the number itself gives a little.
var over = (raw - v) / step * px / mult;
anime.utils.set(num, { x: Math.abs(over) > 1 ? (over > 0 ? 1 : -1) * band(Math.abs(over), 40) : 0 });
}
function release() {
if (!drag) return;
drag = null;
el.classList.remove('scn-held', 'scn-up', 'scn-down');
back = anime.animate(num, { x: 0, duration: reduced() ? 0 : 600, ease: 'outElastic(1, .5)' });
el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: s.v } }));
}
if (!thumb) {
var pid = null, sx = 0, sy = 0, decided = false;
function mult(e) { return e.shiftKey ? 10 : e.altKey ? 0.1 : 1; }
el.addEventListener('pointerdown', function (e) {
if (e.button) return;
pid = e.pointerId; sx = e.clientX; sy = e.clientY;
s.userAt = performance.now();
hideFinger();
decided = e.pointerType === 'mouse';
if (decided) { e.preventDefault(); el.setPointerCapture(pid); el.focus({ preventScroll: true }); grab(sx); }
});
el.addEventListener('pointermove', function (e) {
if (e.pointerId !== pid) return;
s.userAt = performance.now();
if (!decided) {
var dx = Math.abs(e.clientX - sx), dy = Math.abs(e.clientY - sy);
if (dx < 5 && dy < 5) return;
decided = true;
if (dy > dx) { pid = null; return; }
el.setPointerCapture(pid);
grab(sx);
}
moveTo(e.clientX, mult(e));
});
function end(e) { if (e.pointerId !== pid) return; pid = null; release(); }
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
el.addEventListener('keydown', function (e) {
var k = e.key, by = 0;
if (k === 'ArrowUp' || k === 'ArrowRight') by = step;
else if (k === 'ArrowDown' || k === 'ArrowLeft') by = -step;
else if (k === 'PageUp') by = step * 10;
else if (k === 'PageDown') by = -step * 10;
var v = by ? clampV(s.v + by * (e.shiftKey ? 10 : 1)) : k === 'Home' && isFinite(min) ? min : k === 'End' && isFinite(max) ? max : null;
if (v === null) return;
e.preventDefault();
s.userAt = performance.now();
show(v);
el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: v } }));
});
}
show(clampV(s.v), true);
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var finger = document.createElement('span');
finger.className = 'scn-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
function hideFinger() { anime.animate(finger, { opacity: 0, duration: 200 }); }
var group = scope ? [].slice.call(scope.querySelectorAll('[data-scrub-number][data-autoplay]')) : [el];
var turn = Math.max(0, group.indexOf(el)), SWEEP = 2300;
var period = Math.max(+d.autoplay || DEFAULTS.autoplay, group.length * (SWEEP + 400));
var visible = false, timer = 0, flip = 1;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 600 + turn * (SWEEP + 400));
}).observe(el);
// Drag right a way, back left past where it started, then home.
function play() {
if (!visible) return;
var userAt = Math.max.apply(null, group.map(function (g) { return g._scn ? g._scn.userAt : -1e9; }));
if (performance.now() - userAt < 6000 || drag) { timer = setTimeout(play, period); return; }
var span = Math.min(90, Math.max(40, (scope ? scope.clientWidth : 300) * 0.2)) * flip;
flip = -flip;
var p = { x: 0 };
anime.utils.set(finger, { x: 0, scale: 1.3 });
anime.animate(finger, { opacity: 1, scale: 1, duration: 200, ease: 'out(2)' });
grab(0);
anime.createTimeline({
onUpdate: function () { if (drag) { moveTo(p.x, 1); anime.utils.set(finger, { x: p.x }); } },
onComplete: function () {
release();
anime.animate(finger, { opacity: 0, duration: 250 });
timer = setTimeout(play, period);
},
})
.add(p, { x: span, duration: 800, ease: 'inOut(2)', delay: 200 })
.add(p, { x: -span * 0.5, duration: 800, ease: 'inOut(2)' })
.add(p, { x: 0, duration: 500, ease: 'inOut(2)' });
}
}
function init() {
document.querySelectorAll('[data-scrub-number]').forEach(function (el) {
if (el._scn) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-scrub-scope>
<div class="box" style="width: var(--size, 80px)"></div>
Size <span data-scrub-number data-min="40" data-max="120" data-var="--size" data-unit="px">80</span>px
</div>