Back to the library

Momentum — fling into a detent

Variant: Fling into a detent

A picker flung twice, landing once from each side: friction runs out 7px short of a detent on the first throw and 9px past one on the second, and both times the detent finishes the job as part of the same arrival. The naive build is a fling that settles and then a snap animation that fires — the user sees the drum stop and start again, which is one event reported as two. Getting one arrival instead comes down to the seam between the coast and the capture, and to two curves that are wrong for it: an ease-out capture re-accelerates the drum on that frame, an ease-in-out one puts a dead stop there. The window is 99px with a 1.5px border so the content box is exactly three 32px rows, because a picker whose window is not a whole number of rows can never rest with a value centred.

physicsgesturecontrolloop

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>Momentum — fling into a detent</title>
    <style>
      :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)
        );

        /* Clock — every duration in the document runs off --t. */
        --cycle: 4.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* Friction: the coast after release, and the one characterful curve in
           the file. It deliberately does NOT settle — it hands the last of its
           speed to the capture below, which is the whole build. */
        --ease-accent: cubic-bezier(0.12, 0.62, 0.42, 0.9);
        /* The detent's pull. It cannot be --ease-out, whose fast start would
           re-accelerate the drum on the seam frame and turn one arrival into a
           coast plus a snap; and it cannot be --ease-in-out, whose long slow
           start puts a dead stop there instead. This curve leaves at roughly the
           speed the coast arrived with and lands at rest. */
        --_capture: cubic-bezier(0.4, 0.15, 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 {
        width: 176px;
        display: grid;
        gap: 11px;
        justify-items: center;
        animation: rig-cycle var(--t) linear infinite;
      }

      .cap {
        font-size: 11px;
        letter-spacing: 0.04em;
        color: color-mix(in srgb, var(--slate) 52%, transparent);
      }

      .foot {
        opacity: 0;
        animation: foot-in var(--t) linear infinite;
      }

      /* 99px tall with a 1.5px border, so the content box is exactly 96 — three
         32px detents. A picker whose window is not a whole number of rows can
         never rest with a row centred. */
      .picker {
        position: relative;
        width: 132px;
        height: 99px;
        overflow: hidden;
        border: 1.5px solid var(--oat);
        border-radius: calc(11px * var(--round));
        background: var(--paper);
      }

      .band {
        position: absolute;
        left: 0;
        right: 0;
        top: 32px;
        height: 32px;
        background: color-mix(in srgb, var(--clay) 10%, transparent);
        border-top: 1.5px solid color-mix(in srgb, var(--oat) 85%, transparent);
        border-bottom: 1.5px solid color-mix(in srgb, var(--oat) 85%, transparent);
      }

      /* The fade is on the window rather than on the column, so the rim stays
         put while the type rolls under it — masking the column instead would
         drag the soft edges along with the travel. The card's own border is
         outside the mask and stays crisp. */
      .window {
        position: absolute;
        inset: 0;
        overflow: hidden;
        -webkit-mask-image: linear-gradient(
          transparent,
          var(--slate) 24%,
          var(--slate) 76%,
          transparent
        );
        mask-image: linear-gradient(transparent, var(--slate) 24%, var(--slate) 76%, transparent);
      }

      .column {
        display: grid;
        grid-auto-rows: 32px;
        animation: fling var(--t) infinite;
      }

      .item {
        display: grid;
        place-items: center;
        font-size: 14px;
        letter-spacing: 0.02em;
        font-variant-numeric: tabular-nums;
        color: color-mix(in srgb, var(--slate) 40%, transparent);
      }

      /* Colour is the only thing that marks the selection. A weight change would
         re-measure the glyphs and shift the value sideways on the frame it
         locks, which is the one frame the eye is on it. */
      .item:nth-child(2) {
        animation: sel-a var(--t) infinite;
      }
      .item:nth-child(5) {
        animation: sel-b var(--t) infinite;
      }
      .item:nth-child(6) {
        animation: sel-c var(--t) infinite;
      }

      .hand {
        position: absolute;
        right: 7px;
        top: 37px;
        width: 22px;
        height: 22px;
        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:
          hand-move var(--t) infinite,
          hand-grip var(--t) infinite;
      }

      /* Two flings, and the difference between them is which side of a detent
         friction alone would have run out on. The first stops 7px short and the
         detent draws it the rest of the way in; the second sails 9px past and
         the detent draws it back. Both are one arrival with no stop in it —
         that is the whole distinction from a fling that settles and is then
         followed by a separate snap animation. */
      @keyframes fling {
        0%,
        7% {
          transform: translateY(0);
          animation-timing-function: linear;
        }
        7.9% {
          transform: translateY(-14px);
          animation-timing-function: var(--ease-accent);
        }
        20.4% {
          transform: translateY(-89px);
          animation-timing-function: var(--_capture);
        }
        25.1%,
        37% {
          transform: translateY(-96px);
          animation-timing-function: linear;
        }
        37.8% {
          transform: translateY(-106px);
          animation-timing-function: var(--ease-accent);
        }
        48.6% {
          transform: translateY(-137px);
          animation-timing-function: var(--_capture);
        }
        54%,
        95% {
          transform: translateY(-128px);
        }
        95.01%,
        100% {
          transform: translateY(0);
        }
      }

      @keyframes hand-move {
        0%,
        7% {
          transform: translateY(0);
          animation-timing-function: linear;
        }
        7.9%,
        20% {
          transform: translateY(-14px);
        }
        20.01%,
        37% {
          transform: translateY(0);
          animation-timing-function: linear;
        }
        37.8%,
        100% {
          transform: translateY(-10px);
        }
      }

      /* Contact ends on the release frame. Everything after it is friction and
         the detent, and a hand still drawn on the drum would claim otherwise. */
      @keyframes hand-grip {
        0%,
        3.5% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        5%,
        7.9% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        10%,
        34% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        35.5%,
        37.8% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        40%,
        100% {
          opacity: 0;
        }
      }

      /* Each value lights on the frame the drum stops and goes out on the frame
         it starts moving again — a picker has no in-between state, and fading
         the selection across the travel would invent one. */
      @keyframes sel-a {
        0%,
        7% {
          color: var(--clay-d);
        }
        7.01%,
        95% {
          color: color-mix(in srgb, var(--slate) 40%, transparent);
        }
        95.01%,
        100% {
          color: var(--clay-d);
        }
      }

      @keyframes sel-b {
        0%,
        25.09% {
          color: color-mix(in srgb, var(--slate) 40%, transparent);
        }
        25.1%,
        37% {
          color: var(--clay-d);
        }
        37.01%,
        100% {
          color: color-mix(in srgb, var(--slate) 40%, transparent);
        }
      }

      @keyframes sel-c {
        0%,
        53.99% {
          color: color-mix(in srgb, var(--slate) 40%, transparent);
        }
        54%,
        95% {
          color: var(--clay-d);
        }
        95.01%,
        100% {
          color: color-mix(in srgb, var(--slate) 40%, transparent);
        }
      }

      @keyframes foot-in {
        0%,
        26% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        28.5%,
        100% {
          opacity: 1;
        }
      }

      /* The drum re-arms at 95%, inside the dark window, so it never spins
         backwards through four values to get home. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        94%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The travel goes and the detent stays: the drum is parked on a value,
           and the band breathes so the control still reads as live. */
        .rig {
          animation: none;
          opacity: 1;
        }

        .column {
          animation: none;
          transform: translateY(-96px);
        }

        .hand {
          animation: none;
          opacity: 0;
        }

        .item:nth-child(2),
        .item:nth-child(6) {
          animation: none;
        }

        .item:nth-child(5) {
          animation: none;
          color: var(--clay-d);
        }

        .band {
          animation: rest-band calc(var(--t) * 0.8) ease infinite;
        }

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

        @keyframes rest-band {
          0%,
          8% {
            opacity: 0.5;
          }
          40%,
          60% {
            opacity: 1;
          }
          92%,
          100% {
            opacity: 0.5;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A time picker flicked twice: friction runs the drum out just short of one detent and just past the next, and each time the detent draws it the rest of the way in as part of the same arrival"
    >
      <p class="cap">fling · friction · detent</p>
      <div class="picker">
        <span class="band"></span>
        <div class="window">
          <div class="column">
            <span class="item">09:00</span>
            <span class="item">09:15</span>
            <span class="item">09:30</span>
            <span class="item">09:45</span>
            <span class="item">10:00</span>
            <span class="item">10:15</span>
            <span class="item">10:30</span>
            <span class="item">10:45</span>
          </div>
        </div>
        <span class="hand"></span>
      </div>
      <p class="cap foot">the last 7px is the detent</p>
    </div>
  </body>
</html>