Value input — stepper held down
Variant: Stepper hold
Tap and hold are the same timer told apart by whether it completes, so the piece draws the timer: the bar under the button fills at one rate on both presses — the tap releases it half full and nothing extra fires, the hold lets it finish and the repeat stream begins on that exact frame. Every increment lands on the down-frame, never the release, because immediacy is the one job a stepper has. The repeats accelerate from 336ms to 156ms — a repeat that holds its rate is a metronome, not a control gathering speed — and the loop ends the way real steppers do: the hold overshoots to 20 and a single minus tap walks it back. The correction is part of the gesture, not a failure of it.
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 — stepper held down</title>
<style>
@property --_n {
syntax: '<integer>';
inherits: true;
initial-value: 12;
}
: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);
--_pressed: color-mix(in srgb, var(--oat) 45%, var(--paper));
/* Clock — 6s: one tap, one hold, one correction. The hold fires at
20% and the stream starts at 27.7% — the 460ms between them is the
armed delay, drawn as the bar under the button. The repeats then
land 336, 276, 228, 186 and 156ms apart. */
--cycle: 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);
--ease-accent: cubic-bezier(0.32, 0.72, 0, 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: 16px;
justify-items: center;
animation:
rig-cycle var(--t) linear infinite,
count var(--t) linear 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;
}
.pad {
position: relative;
display: flex;
align-items: center;
gap: 10px;
}
.btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: calc(9px * var(--round));
background: var(--paper);
border: 1.5px solid var(--oat);
font-size: 17px;
font-weight: 600;
line-height: 1;
color: var(--slate);
}
.minus {
animation: minus-press var(--t) infinite;
}
.plus {
animation: plus-press var(--t) infinite;
}
.val {
min-width: 46px;
text-align: center;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
}
.val::before {
counter-reset: n var(--_n);
content: counter(n);
}
/* The armed delay, drawn. Both presses start the same timer on their
down-frame: the tap releases it half full and nothing extra fires;
the hold lets it fill, and the repeat stream begins on the frame it
completes. */
.arm {
position: absolute;
right: 4px;
bottom: -9px;
width: 28px;
height: 2.5px;
border-radius: 1.25px;
background: color-mix(in srgb, var(--oat) 60%, transparent);
opacity: 0;
overflow: hidden;
animation: arm-show var(--t) linear infinite;
}
.arm i {
position: absolute;
inset: 0;
background: var(--clay);
transform-origin: left center;
transform: scaleX(0);
animation: arm-fill var(--t) linear infinite;
}
.ghost {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 18px;
opacity: 0;
animation:
hand-move var(--t) infinite,
hand-in var(--t) linear infinite;
}
.arrow {
display: block;
width: 100%;
height: 100%;
fill: var(--slate);
stroke: var(--ivory);
stroke-width: 1.4;
stroke-linejoin: round;
}
.tip {
position: absolute;
left: -9px;
top: -9px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--clay-d);
opacity: 0;
transform: scale(0.5);
animation: grip var(--t) infinite;
}
/* Every value fires on the down-frame, never the up — a stepper that
waits for the release reads as lag on the one control whose whole
job is immediacy. The plateaus are the cadence: 336ms shrinking to
156ms, because a repeat that holds one rate feels like a metronome
rather than a control gathering speed. */
@keyframes count {
0%,
7% {
--_n: 12;
}
7.2%,
20% {
--_n: 13;
}
20.2%,
27.7% {
--_n: 14;
}
27.9%,
33.3% {
--_n: 15;
}
33.5%,
37.9% {
--_n: 16;
}
38.1%,
41.7% {
--_n: 17;
}
41.9%,
44.8% {
--_n: 18;
}
45%,
47.4% {
--_n: 19;
}
47.6%,
66% {
--_n: 20;
}
66.2%,
95.5% {
--_n: 19;
}
97.5%,
100% {
--_n: 12;
}
}
/* 90ms down, 190ms up, exactly the library's tap — except the second
press has no up for 1.8 seconds, and the pressed state simply holds:
pressed tracks contact, not the clock the press started on. */
@keyframes plus-press {
0%,
7% {
transform: scale(1);
background: var(--paper);
animation-timing-function: var(--ease-accent);
}
8.5%,
11% {
transform: scale(0.94);
background: var(--_pressed);
animation-timing-function: var(--ease-out);
}
14.2%,
20% {
transform: scale(1);
background: var(--paper);
animation-timing-function: var(--ease-accent);
}
21.5%,
50.5% {
transform: scale(0.94);
background: var(--_pressed);
animation-timing-function: var(--ease-out);
}
53.7%,
100% {
transform: scale(1);
background: var(--paper);
}
}
@keyframes minus-press {
0%,
66% {
transform: scale(1);
background: var(--paper);
animation-timing-function: var(--ease-accent);
}
67.5%,
69% {
transform: scale(0.94);
background: var(--_pressed);
animation-timing-function: var(--ease-out);
}
72.2%,
100% {
transform: scale(1);
background: var(--paper);
}
}
@keyframes arm-show {
0%,
6.5% {
opacity: 0;
}
7%,
11% {
opacity: 1;
}
11.5%,
19.5% {
opacity: 0;
}
20%,
27.7% {
opacity: 1;
}
29.2%,
100% {
opacity: 0;
}
}
/* One fill rate for both presses — it is the same timer, running from
each down-frame. The tap covers 4% of the cycle before its release
against the hold's full 7.7%, so it stops at 0.52 — and resets to
empty rather than draining: intent is not a quantity you keep some
of after the hand has gone. */
@keyframes arm-fill {
0%,
7% {
transform: scaleX(0);
}
11% {
transform: scaleX(0.52);
}
11.2%,
20% {
transform: scaleX(0);
}
27.7%,
28.5% {
transform: scaleX(1);
}
28.7%,
100% {
transform: scaleX(0);
}
}
@keyframes hand-move {
0%,
2% {
transform: translate(158px, 66px);
animation-timing-function: var(--ease-out);
}
6.5%,
55% {
transform: translate(120px, 19px);
animation-timing-function: var(--ease-in-out);
}
61%,
75% {
transform: translate(18px, 19px);
animation-timing-function: var(--ease-out);
}
82%,
100% {
transform: translate(4px, 62px);
}
}
@keyframes hand-in {
0%,
2% {
opacity: 0;
}
6.5%,
76% {
opacity: 1;
}
82%,
100% {
opacity: 0;
}
}
@keyframes grip {
0%,
7% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
8.5%,
11% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
13.5%,
20% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
21.5%,
50.5% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
53%,
66% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
67.5%,
70% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
73%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
@keyframes foot-in {
0%,
73% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
77%,
90% {
opacity: 1;
}
94%,
100% {
opacity: 0;
}
}
@keyframes rig-cycle {
0% {
opacity: 0;
}
2.5%,
91% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The counting is the meaning and it stays, at 1.6x, with the armed
bar still filling — but the buttons answer in colour only and the
hand is gone. */
.rig,
.minus,
.plus,
.arm,
.arm i,
.foot {
animation-duration: calc(var(--t) * 1.6);
}
.ghost {
animation: none;
}
.minus {
animation-name: minus-rm;
}
.plus {
animation-name: plus-rm;
}
@keyframes plus-rm {
0%,
7% {
background: var(--paper);
}
8.5%,
11% {
background: var(--_pressed);
}
14.2%,
20% {
background: var(--paper);
}
21.5%,
50.5% {
background: var(--_pressed);
}
53.7%,
100% {
background: var(--paper);
}
}
@keyframes minus-rm {
0%,
66% {
background: var(--paper);
}
67.5%,
69% {
background: var(--_pressed);
}
72.2%,
100% {
background: var(--paper);
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A stepper tapped once, then held: after an armed pause the increments accelerate from 14 to 20, overshoot, and a final minus tap settles on 19"
>
<p class="cap">tap · hold · auto-repeat</p>
<div class="pad">
<span class="btn minus">−</span>
<span class="val"></span>
<span class="btn plus">+</span>
<span class="arm"><i></i></span>
<span class="ghost">
<span class="tip"></span>
<svg class="arrow" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1 L1 14.8 L4.5 11.6 L6.7 16.6 L9.3 15.4 L7.1 10.5 L11.8 10.3 Z" />
</svg>
</span>
</div>
<p class="foot">the hold overshoots — one tap walks it back</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.