Back to the library

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.

controlgesturecounterdataloop

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>