Value input — scrubbed number
Variant: Scrub field
A numeric field dragged on the value itself, built the only honest way: the cursor’s offset, the readout and the gauge are pure functions of one registered number, so the claim a scrubber makes — the value is the hand’s distance, nothing else — is true by construction rather than by tuning. The two legs ease because the hand accelerates and stops, and that is the only easing the value ever gets; it cannot be eased separately because it is the same variable. The overshoot to 72 and the hunt back to 48 is what scrubbing actually looks like — aiming at a number you can only read while it moves. The commit is one 400ms darkening of the gauge on the release frame; the number itself never flashes, because a value that changes colour to announce it changed is saying it twice.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Value input — scrubbed number</title>
<style>
/* One registered number is the whole control: the cursor's offset, the
readout and the gauge are all pure functions of it. --_p carries the
smooth geometry, --_v the rounded readout; both ride one keyframes
block, so nothing here can be out of step with anything else. */
@property --_p {
syntax: '<number>';
inherits: true;
initial-value: 40;
}
@property --_v {
syntax: '<integer>';
inherits: true;
initial-value: 40;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
--_muted: color-mix(in srgb, var(--slate) 52%, transparent);
/* Clock — 5.6s: press, a 950ms scrub out to 72, a beat of reading,
a 670ms hunt back, commit at 48. The overshoot is not a flourish —
scrubbing is aiming at a number you can only read while moving,
and the hunt back is what using one actually looks like. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.rig {
display: grid;
gap: 14px;
justify-items: center;
width: min(290px, 88vw);
animation:
rig-cycle var(--t) linear infinite,
value var(--t) infinite;
}
.cap {
font-size: 11px;
letter-spacing: 0.04em;
color: var(--_muted);
}
.foot {
font-size: 11px;
letter-spacing: 0.02em;
color: var(--_muted);
opacity: 0;
animation: foot-in var(--t) linear infinite;
}
.zone {
position: relative;
width: 168px;
}
.chip {
display: grid;
gap: 9px;
width: 100%;
padding: 12px 14px;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1.5px solid var(--oat);
animation: chip-state var(--t) infinite;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label {
font-size: 12px;
color: var(--_muted);
}
.num {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.num::before {
counter-reset: v var(--_v);
content: counter(v) '%';
}
.gauge {
position: relative;
height: 3px;
border-radius: 1.5px;
background: color-mix(in srgb, var(--oat) 60%, transparent);
overflow: hidden;
}
.gauge i {
position: absolute;
inset: 0;
background: var(--clay);
transform-origin: left center;
transform: scaleX(calc(var(--_p) / 100));
animation: commit var(--t) infinite;
}
/* The scrub cursor. The outer layer arrives and leaves; the inner one
has no keyframes at all — its offset is 2px per unit of --_p, so it
is welded to the value by construction rather than by tuning. */
.cur {
position: absolute;
left: 0;
top: 0;
width: 22px;
height: 10px;
margin-left: -11px;
margin-top: -5px;
opacity: 0;
animation:
cur-move var(--t) infinite,
cur-in var(--t) linear infinite;
}
.curx {
position: absolute;
inset: 0;
transform: translateX(calc((var(--_p) - 40) * 2px));
}
.ew {
display: block;
width: 100%;
height: 100%;
fill: var(--slate);
stroke: var(--ivory);
stroke-width: 1;
stroke-linejoin: round;
}
.tip {
position: absolute;
left: 1px;
top: -5px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--clay-d);
opacity: 0;
transform: scale(0.5);
animation: grip var(--t) infinite;
}
/* Both legs are eased because the hand accelerates and stops — and
that is the only easing in the file. The value cannot be eased
separately, differently, or at all: it is the same number. */
@keyframes value {
0%,
13% {
--_p: 40;
--_v: 40;
animation-timing-function: var(--ease-in-out);
}
30%,
36% {
--_p: 72;
--_v: 72;
animation-timing-function: var(--ease-in-out);
}
48%,
94.5% {
--_p: 48;
--_v: 48;
}
97%,
100% {
--_p: 40;
--_v: 40;
}
}
@keyframes cur-move {
0%,
4% {
transform: translate(148px, 74px);
animation-timing-function: var(--ease-out);
}
12%,
54% {
transform: translate(137px, 22px);
animation-timing-function: var(--ease-out);
}
62%,
100% {
transform: translate(188px, 48px);
}
}
@keyframes cur-in {
0%,
4% {
opacity: 0;
}
11%,
55% {
opacity: 1;
}
62%,
100% {
opacity: 0;
}
}
@keyframes grip {
0%,
12% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
13.5%,
49% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
51.5%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* The field holds an editing state for exactly the span of contact —
border and wash, no motion, because the number is already moving. */
@keyframes chip-state {
0%,
12.5% {
border-color: var(--oat);
background: var(--paper);
}
13.5%,
49.5% {
border-color: color-mix(in srgb, var(--clay) 45%, var(--oat));
background: color-mix(in srgb, var(--clay) 4%, var(--paper));
}
52%,
100% {
border-color: var(--oat);
background: var(--paper);
}
}
/* The commit: one brief darkening of the gauge on the release frame,
decaying over 400ms. The value does not flash — a number that
changes colour to say it changed is saying it twice. */
@keyframes commit {
0%,
49.9% {
background: var(--clay);
}
50.4%,
51.5% {
background: var(--clay-d);
}
58.65%,
100% {
background: var(--clay);
}
}
@keyframes foot-in {
0%,
58% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
62%,
88% {
opacity: 1;
}
93%,
100% {
opacity: 0;
}
}
@keyframes rig-cycle {
0% {
opacity: 0;
}
2.5%,
90% {
opacity: 1;
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* Parked on the committed 48 with the cursor gone; the gauge fill
breathes so the field still reads as live. */
.rig {
animation: none;
--_p: 48;
--_v: 48;
}
.cur {
animation: none;
}
.chip {
animation: none;
}
.foot {
animation: none;
opacity: 1;
}
.gauge i {
animation: rest-fill calc(var(--t) * 0.8) ease infinite;
}
@keyframes rest-fill {
0%,
8% {
opacity: 0.62;
}
40%,
60% {
opacity: 1;
}
92%,
100% {
opacity: 0.62;
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A numeric field scrubbed by dragging the value itself: the number rides the cursor out to 72, hunts back, and commits at 48"
>
<p class="cap">scrub · hunt · commit</p>
<div class="zone">
<div class="chip">
<div class="row">
<span class="label">Opacity</span>
<span class="num"></span>
</div>
<div class="gauge"><i></i></div>
</div>
<span class="cur">
<span class="curx">
<span class="tip"></span>
<svg class="ew" viewBox="0 0 22 10" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 5 L6.5 1 L6.5 3.9 L15.5 3.9 L15.5 1 L22 5 L15.5 9 L15.5 6.1 L6.5 6.1 L6.5 9 Z"
/>
</svg>
</span>
</span>
</div>
<p class="foot">no second timeline — the number is the hand</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.