Back to the library

Momentum — caught mid-flight

Variant: Caught mid-flight

What separates a spring from a tween, shown on the only frame where it matters: two pucks leave together for the same target and have it moved behind them 150ms in, while both are still travelling hard. The restarted lane does what a duration-and-easing tween does when you cancel and replay it — ease-out leaves its origin fast, so the puck does not stop, it reverses at speed and the path gets a corner in it. That corner is the artifact people are pointing at when they call an interrupted animation janky, and no amount of curve-picking removes it, because the problem is that the new animation has no idea what the old one was doing. The lane carrying its velocity coasts 12px past the catch point, turns, and comes back. It costs 160ms and it never has a speed it did not have a frame earlier.

physicsdiagramtransitionloop

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 — caught mid-flight</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: 3.2s;
        --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);
        /* The launch, and the one characterful curve here: the first 150ms of a
           long deceleration, so both pucks are still travelling hard on the frame
           the target moves. Interrupt a curve that has already slowed down and
           there is no velocity left for either lane to disagree about. */
        --ease-accent: cubic-bezier(0.15, 0.55, 0.5, 0.94);
      }

      * {
        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: 200px;
        display: grid;
        gap: 12px;
        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;
      }

      .lane {
        display: grid;
        gap: 5px;
      }

      .tag {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        font-size: 11px;
        letter-spacing: 0.02em;
        font-variant-numeric: tabular-nums;
        color: color-mix(in srgb, var(--slate) 62%, transparent);
      }

      .took {
        color: var(--clay-d);
        opacity: 0;
      }

      .l1 .took {
        animation: took-restart var(--t) steps(1, end) infinite;
      }
      .l2 .took {
        animation: took-carry var(--t) steps(1, end) infinite;
      }

      .track {
        position: relative;
        height: 27px;
      }

      .track::before {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        bottom: 5px;
        height: 2px;
        background: var(--oat);
      }

      /* Targets sit under the rail rather than across it, so the puck can pass
         over one without ever hiding it. */
      .mark {
        position: absolute;
        bottom: 0;
        width: 1.5px;
        height: 4px;
        background: color-mix(in srgb, var(--slate) 34%, transparent);
      }

      .a {
        left: 150px;
        animation: mark-a var(--t) infinite;
      }

      .b {
        left: 40px;
        background: var(--clay-d);
        opacity: 0;
        animation: mark-b var(--t) infinite;
      }

      .puck {
        position: absolute;
        left: 0;
        bottom: 7px;
        width: 18px;
        height: 18px;
        border-radius: calc(5px * var(--round));
        background: var(--clay);
      }

      /* Both pucks are the same colour and leave on the same frame. Everything
         that differs between the lanes happens after 150ms, which is the only
         way the comparison is worth anything. */
      .l1 .puck {
        animation: restart var(--t) infinite;
      }
      .l2 .puck {
        animation: carry var(--t) infinite;
      }

      /* A tween restarted on the interrupt frame does not stop — it reverses at
         speed, because ease-out leaves its origin fast. The path gets a corner
         in it, and a corner is the artifact people are describing when they
         call an interrupted animation janky. */
      @keyframes restart {
        0%,
        8% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        12.7% {
          transform: translateX(120px);
          animation-timing-function: var(--ease-out);
        }
        24.6%,
        94% {
          transform: translateX(40px);
        }
        94.01%,
        100% {
          transform: translateX(0);
        }
      }

      /* The same interrupt with the puck's speed carried into the new target:
         90ms of coasting to an apex 12px past where it was caught, then a turn.
         It never has a velocity it did not have a frame earlier — which is what
         costs it 160ms and buys it a path with no corner. The turn is
         ease-in-out because a body reversing leaves rest and arrives at rest;
         only the last 4px of recovery are ease-out. */
      @keyframes carry {
        0%,
        8% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        12.7% {
          transform: translateX(120px);
          animation-timing-function: var(--ease-out);
        }
        15.5% {
          transform: translateX(132px);
          animation-timing-function: var(--ease-in-out);
        }
        25% {
          transform: translateX(36px);
          animation-timing-function: var(--ease-out);
        }
        29.4%,
        94% {
          transform: translateX(40px);
        }
        94.01%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes mark-a {
        0%,
        12.7% {
          opacity: 1;
          animation-timing-function: linear;
        }
        14.7%,
        100% {
          opacity: 0;
        }
      }

      @keyframes mark-b {
        0%,
        12.7% {
          opacity: 0;
          animation-timing-function: linear;
        }
        14.7%,
        100% {
          opacity: 1;
        }
      }

      @keyframes took-restart {
        0%,
        24.59% {
          opacity: 0;
        }
        24.6%,
        100% {
          opacity: 1;
        }
      }

      @keyframes took-carry {
        0%,
        29.39% {
          opacity: 0;
        }
        29.4%,
        100% {
          opacity: 1;
        }
      }

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

      /* Pucks re-arm at 94%, inside the dark window, so neither lane is ever
         seen running back to the start line. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        86% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Both pucks are parked on the second target, since where they ended up
           was never in dispute; the two settle times alternate instead, because
           the cost of restarting is the only thing left to report. */
        .rig {
          animation: none;
          opacity: 1;
        }

        .puck {
          animation: none !important;
          transform: translateX(40px);
        }

        .a {
          animation: none;
          opacity: 0;
        }
        .b {
          animation: none;
          opacity: 1;
        }

        .took {
          animation: rest-cue calc(var(--t) * 0.9) ease infinite !important;
        }
        .l2 .took {
          animation-delay: calc(0.28s / var(--speed)) !important;
        }

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

        @keyframes rest-cue {
          0%,
          10% {
            opacity: 0.25;
          }
          26%,
          64% {
            opacity: 1;
          }
          82%,
          100% {
            opacity: 0.25;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="Two pucks launched at the same target have it moved behind them 150 milliseconds in: the restarted one reverses direction on the spot, the one carrying its velocity coasts past, turns, and comes back"
    >
      <p class="cap">target moves mid-flight</p>
      <div class="lane l1">
        <p class="tag"><span>restarted from zero</span><span class="took">0.38s</span></p>
        <div class="track">
          <span class="mark a"></span><span class="mark b"></span><span class="puck"></span>
        </div>
      </div>
      <div class="lane l2">
        <p class="tag"><span>velocity carried</span><span class="took">0.54s</span></p>
        <div class="track">
          <span class="mark a"></span><span class="mark b"></span><span class="puck"></span>
        </div>
      </div>
      <p class="cap foot">only one path has a corner in it</p>
    </div>
  </body>
</html>