Back to the library

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.

controlgesturecounterloop

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.