Back to the library

Value input — ratchet dial

Variant: Ratchet dial

The finger is continuous and the value is not — that disagreement is the whole piece. A contact dot sweeps 120° of rim at a constant rate; the pointer under it moves only four times, on the frames the finger crosses each detent’s threshold: 110ms out, 4.4° of overshoot, 87ms pulled back onto the notch. The overshoot lives in the keyframe values rather than a springy curve, so a speed remix can never fling it past the next detent. Between clicks the pointer holds dead still, because a detented shaft has no in-between positions, and the readout changes on the click frame — when the pawl drops, not when the finger moves. Reach for it anywhere a smooth gesture sets a stepped value: zoom levels, font sizes, anything quantized.

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 — ratchet dial</title>
    <style>
      @property --_n {
        syntax: '<integer>';
        inherits: true;
        initial-value: 1;
      }

      :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;
        --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);
        --_tick: color-mix(in srgb, var(--slate) 24%, transparent);

        /* Clock — 5.8s: one smooth 120° turn of the hand, four clicks of the
           pointer. The finger sweeps at a constant rate from 10% to 38%; the
           pointer moves only at 13.5, 20.5, 27.5 and 34.5% — the frames the
           finger crosses each detent's threshold — 110ms out with 4.4° of
           overshoot, 87ms back. */
        --cycle: 5.8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* The pawl's flick — the click itself. It leaves hard because the
           spring has been loading the whole time the finger was turning, and
           the overshoot is drawn in the keyframe values, so a speed remix can
           never fling it past the next notch. */
        --ease-accent: cubic-bezier(0.2, 0.9, 0.25, 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: 8px;
        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;
      }

      .dial {
        position: relative;
        width: 172px;
        height: 116px;
      }

      /* Nine detent marks on a 240° arc, lit cumulatively as the pointer
         reaches them. */
      .tick {
        position: absolute;
        left: 85px;
        top: 66px;
        width: 2px;
        height: 7px;
        border-radius: 1px;
        background: var(--_tick);
        transform-origin: 50% 0;
      }

      .tick.d0 {
        transform: rotate(-120deg) translateY(-62px);
        background: var(--clay-d);
      }
      .tick.d1 {
        transform: rotate(-90deg) translateY(-62px);
        background: var(--clay-d);
      }
      .tick.d2 {
        transform: rotate(-60deg) translateY(-62px);
        animation: lit2 var(--t) infinite;
      }
      .tick.d3 {
        transform: rotate(-30deg) translateY(-62px);
        animation: lit3 var(--t) infinite;
      }
      .tick.d4 {
        transform: rotate(0deg) translateY(-62px);
        animation: lit4 var(--t) infinite;
      }
      .tick.d5 {
        transform: rotate(30deg) translateY(-62px);
        animation: lit5 var(--t) infinite;
      }
      .tick.d6 {
        transform: rotate(60deg) translateY(-62px);
      }
      .tick.d7 {
        transform: rotate(90deg) translateY(-62px);
      }
      .tick.d8 {
        transform: rotate(120deg) translateY(-62px);
      }

      .knob {
        position: absolute;
        left: 44px;
        top: 24px;
        width: 84px;
        height: 84px;
        border-radius: 50%;
        background: var(--paper);
        border: 1.5px solid var(--oat);
        box-shadow: 0 3px 10px color-mix(in srgb, var(--slate) 14%, transparent);
      }

      .rotor {
        position: absolute;
        inset: 0;
        animation: clicks var(--t) infinite;
      }

      .needle {
        position: absolute;
        left: calc(50% - 1.5px);
        top: 6px;
        width: 3px;
        height: 16px;
        border-radius: 1.5px;
        background: var(--clay-d);
      }

      .readout {
        position: absolute;
        inset: 0;
        display: grid;
        place-items: center;
        font-size: 20px;
        font-weight: 700;
        font-variant-numeric: tabular-nums;
      }

      .readout::before {
        counter-reset: n var(--_n);
        content: counter(n);
      }

      /* The finger: a contact dot riding the rim on its own rotating layer.
         Its motion is linear from grip to release — the whole piece is the
         disagreement between this line and the stepped one under it. */
      .orbit {
        position: absolute;
        inset: 0;
        animation: sweep var(--t) infinite;
      }

      .fingertip {
        position: absolute;
        left: calc(50% - 6px);
        top: -14px;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--slate) 13%, transparent);
        border: 1.5px solid color-mix(in srgb, var(--slate) 30%, transparent);
        opacity: 0;
        animation: fingertip-in var(--t) linear infinite;
      }

      /* Four clicks. Each one holds dead still while the finger closes on
         the next threshold — a detented shaft has no in-between positions —
         then covers 30° in 110ms, lands 4.4° long, and is pulled back onto
         the notch in 87ms. */
      @keyframes clicks {
        0%,
        13.5% {
          transform: rotate(-90deg);
          animation-timing-function: var(--ease-accent);
        }
        15.4% {
          transform: rotate(-55.6deg);
          animation-timing-function: var(--ease-out);
        }
        16.9%,
        20.5% {
          transform: rotate(-60deg);
          animation-timing-function: var(--ease-accent);
        }
        22.4% {
          transform: rotate(-25.6deg);
          animation-timing-function: var(--ease-out);
        }
        23.9%,
        27.5% {
          transform: rotate(-30deg);
          animation-timing-function: var(--ease-accent);
        }
        29.4% {
          transform: rotate(4.4deg);
          animation-timing-function: var(--ease-out);
        }
        30.9%,
        34.5% {
          transform: rotate(0deg);
          animation-timing-function: var(--ease-accent);
        }
        36.4% {
          transform: rotate(34.4deg);
          animation-timing-function: var(--ease-out);
        }
        37.9%,
        95.5% {
          transform: rotate(30deg);
        }
        98%,
        100% {
          transform: rotate(-90deg);
        }
      }

      @keyframes sweep {
        0%,
        10% {
          transform: rotate(-90deg);
          animation-timing-function: linear;
        }
        38%,
        100% {
          transform: rotate(30deg);
        }
      }

      @keyframes fingertip-in {
        0%,
        5% {
          opacity: 0;
        }
        9%,
        39% {
          opacity: 1;
        }
        44%,
        100% {
          opacity: 0;
        }
      }

      /* The value changes on the click frame — when the pawl drops, not when
         the finger moves. */
      @keyframes count {
        0%,
        13.4% {
          --_n: 1;
        }
        13.7%,
        20.4% {
          --_n: 2;
        }
        20.7%,
        27.4% {
          --_n: 3;
        }
        27.7%,
        34.4% {
          --_n: 4;
        }
        34.7%,
        95.5% {
          --_n: 5;
        }
        98%,
        100% {
          --_n: 1;
        }
      }

      /* Each mark lights on the same frames the count steps — the click,
         the value and the scale are one event, not three timelines. */
      @keyframes lit2 {
        0%,
        13.4% {
          background: var(--_tick);
        }
        13.7%,
        95.5% {
          background: var(--clay-d);
        }
        98%,
        100% {
          background: var(--_tick);
        }
      }

      @keyframes lit3 {
        0%,
        20.4% {
          background: var(--_tick);
        }
        20.7%,
        95.5% {
          background: var(--clay-d);
        }
        98%,
        100% {
          background: var(--_tick);
        }
      }

      @keyframes lit4 {
        0%,
        27.4% {
          background: var(--_tick);
        }
        27.7%,
        95.5% {
          background: var(--clay-d);
        }
        98%,
        100% {
          background: var(--_tick);
        }
      }

      @keyframes lit5 {
        0%,
        34.4% {
          background: var(--_tick);
        }
        34.7%,
        95.5% {
          background: var(--clay-d);
        }
        98%,
        100% {
          background: var(--_tick);
        }
      }

      @keyframes foot-in {
        0%,
        41% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        45%,
        90% {
          opacity: 1;
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      /* The pointer re-arms inside the dark window, so it is never seen
         winding back through four values. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        90% {
          opacity: 1;
        }
        95%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Parked on 5 with the finger gone. The lit detents breathe together
           so the dial still reads as held at a value rather than switched off. */
        .rig {
          animation: none;
          --_n: 5;
        }

        .rotor {
          animation: none;
          transform: rotate(30deg);
        }

        .orbit,
        .fingertip {
          animation: none;
        }

        .foot {
          animation: none;
          opacity: 1;
        }

        .tick.d2,
        .tick.d3,
        .tick.d4,
        .tick.d5 {
          animation: rest-lit calc(var(--t) * 0.8) ease infinite;
        }

        @keyframes rest-lit {
          0%,
          8% {
            background: color-mix(in srgb, var(--clay-d) 55%, var(--_tick));
          }
          40%,
          60% {
            background: var(--clay-d);
          }
          92%,
          100% {
            background: color-mix(in srgb, var(--clay-d) 55%, var(--_tick));
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A dial wound through four detents by one smooth turn of the finger: the pointer advances in discrete clicks, overshooting each notch and settling back onto it"
    >
      <p class="cap">sweep · click · settle</p>
      <div class="dial">
        <i class="tick d0"></i>
        <i class="tick d1"></i>
        <i class="tick d2"></i>
        <i class="tick d3"></i>
        <i class="tick d4"></i>
        <i class="tick d5"></i>
        <i class="tick d6"></i>
        <i class="tick d7"></i>
        <i class="tick d8"></i>
        <div class="knob">
          <div class="rotor"><span class="needle"></span></div>
          <span class="readout"></span>
          <div class="orbit"><span class="fingertip"></span></div>
        </div>
      </div>
      <p class="foot">the finger is continuous — the value is not</p>
    </div>
  </body>
</html>