Back to the library

Coupling — momentum through contact

Variant: Momentum through contact

A block slides into three touching blocks, stops dead, and the far one leaves — while the two in the middle are never given a keyframe at all, which is the only honest way to draw a body that did not move. Two decisions carry it. The struck block departs six milliseconds after the striker stops, inside the same frame at any refresh rate anyone owns: put a tenth of a second between them and the transfer stops being one event and becomes two animations that happen to be adjacent, which is the version almost everyone ships. And all three contact faces light on the same frame rather than in sequence, since a shock front does not take 100ms to cross three touching bodies — only the decay is staggered, longest at the far end, so the light reads as having travelled without any of it arriving late. The striker coasts linear because its speed is the quantity about to be handed over, and both 48px spans are standing rules rather than timed ones, so the equal rate is something you can hold against a ruler.

physicsdiagramlistloop

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>Coupling — momentum through contact</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: 3s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 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);
      }

      .scene {
        position: relative;
        width: 238px;
        height: 112px;
      }

      .rail {
        position: absolute;
        top: 74px;
        left: 0;
        right: 0;
        height: 2px;
        background: var(--oat);
      }

      .rig {
        position: absolute;
        inset: 0;
        animation: rig-cycle var(--t) linear infinite;
      }

      .puck {
        position: absolute;
        top: 54px;
        width: 20px;
        height: 20px;
        border-radius: calc(4px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
      }

      .striker {
        left: 6px;
        background: var(--clay);
        border-color: var(--clay);
        animation: strike var(--t) infinite;
      }

      /* Two bodies that are never given an animation at all. Everything the
         piece claims about them has to be true of an element with no keyframes
         on it, which is the only honest way to draw "these did not move". */
      .p1 {
        left: 74px;
      }
      .p2 {
        left: 95px;
      }

      .p3 {
        left: 116px;
        animation: depart var(--t) infinite;
      }

      /* The contact faces. All three light on the same frame — a shock front
         does not take 100ms to cross three touching bodies — and it is only the
         decay that is staggered, longest at the far end, so the light reads as
         having travelled without any of it arriving late. */
      .face {
        position: absolute;
        top: 54px;
        width: 3px;
        height: 20px;
        border-radius: 2px;
        background: var(--clay-d);
        opacity: 0;
      }

      .f1 {
        left: 72.5px;
        animation: pass-1 var(--t) infinite;
      }
      .f2 {
        left: 93px;
        animation: pass-2 var(--t) infinite;
      }
      .f3 {
        left: 114px;
        animation: pass-3 var(--t) infinite;
      }

      /* Two spans of exactly 48px, drawn once and never animated: what the
         striker covered, and what the struck body covers before friction takes
         over. Equal rate is the claim, and a claim you can hold a ruler to is
         worth more than one the timing merely implies. */
      .span {
        position: absolute;
        top: 88px;
        width: 48px;
        height: 1.5px;
        background: var(--oat);
      }

      .span::before,
      .span::after {
        content: '';
        position: absolute;
        top: -6px;
        width: 1.5px;
        height: 7px;
        background: var(--oat);
      }

      .span::before {
        left: 0;
      }
      .span::after {
        right: 0;
      }

      .span-a {
        left: 6px;
      }
      .span-b {
        left: 116px;
      }

      .tick {
        position: absolute;
        top: 94px;
        width: 48px;
        text-align: center;
        font-size: 11px;
        letter-spacing: 0.02em;
        font-variant-numeric: tabular-nums;
        color: color-mix(in srgb, var(--slate) 55%, transparent);
      }

      .tick-a {
        left: 6px;
      }
      .tick-b {
        left: 116px;
      }

      /* Linear, and nothing else would do: the striker is coasting, and its
         speed is the quantity about to be handed over. Ease it and the number
         being transferred changes every frame. */
      @keyframes strike {
        0%,
        6% {
          transform: translateX(0);
          animation-timing-function: linear;
        }
        18% {
          transform: translateX(48px);
          animation-timing-function: var(--ease-out);
        }
        19.2%,
        91% {
          transform: translateX(49.5px);
        }
        92%,
        100% {
          transform: translateX(0);
        }
      }

      /* Leaves at 18.2% — six milliseconds after the striker stopped, which is
         inside the same frame at any refresh rate anyone owns. It covers its
         first 48px in the same 12% of the clock the striker spent, so the two
         rates are identical, and only then does friction round it off. */
      @keyframes depart {
        0%,
        18.2% {
          transform: translateX(0);
          animation-timing-function: linear;
        }
        30.2% {
          transform: translateX(48px);
          animation-timing-function: var(--ease-out);
        }
        39%,
        91% {
          transform: translateX(66px);
        }
        92%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes pass-1 {
        0%,
        17.6% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        18.2% {
          opacity: 0.95;
          animation-timing-function: var(--ease-out);
        }
        20.2%,
        100% {
          opacity: 0;
        }
      }

      @keyframes pass-2 {
        0%,
        17.6% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        18.2% {
          opacity: 0.95;
          animation-timing-function: var(--ease-out);
        }
        21%,
        100% {
          opacity: 0;
        }
      }

      @keyframes pass-3 {
        0%,
        17.6% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        18.2% {
          opacity: 0.95;
          animation-timing-function: var(--ease-out);
        }
        21.8%,
        100% {
          opacity: 0;
        }
      }

      /* Both movers are re-seated at 92%, inside the window the rig spends at
         zero, so the strike never plays in reverse. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        86% {
          opacity: 1;
        }
        90%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* All travel goes, and the piece survives it better than most: what is
           left running is the subject itself — force crossing three bodies that
           were never going to move in the first place. */
        .rig {
          animation: none;
          opacity: 1;
        }

        .striker,
        .p3 {
          animation: none;
          transform: none;
        }

        .face {
          animation: rest-pass calc(var(--t) * 1.6) ease infinite !important;
        }

        .f2 {
          animation-delay: calc(0.11s / var(--speed)) !important;
        }

        .f3 {
          animation-delay: calc(0.22s / var(--speed)) !important;
        }

        @keyframes rest-pass {
          0%,
          8% {
            opacity: 0;
          }
          16%,
          26% {
            opacity: 0.9;
          }
          38%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="scene"
      role="img"
      aria-label="A block slides into a row of three touching blocks and stops dead; the two in the middle never move, and the far one leaves at the same speed the striker arrived with"
    >
      <div class="rail"></div>
      <span class="span span-a"></span>
      <span class="span span-b"></span>
      <span class="tick tick-a">48px</span>
      <span class="tick tick-b">48px</span>
      <div class="rig">
        <span class="puck striker"></span>
        <span class="puck p1"></span>
        <span class="puck p2"></span>
        <span class="puck p3"></span>
        <span class="face f1"></span>
        <span class="face f2"></span>
        <span class="face f3"></span>
      </div>
    </div>
  </body>
</html>