Back to the library

Coupling — a chain in series

Variant: Chain in series

Five rows where only the head is held and every row below is coupled to the row above it rather than to the head — which is the whole difference between this and a stagger. Give all four followers the same animation-delay and each one travels the same 44px, only later; couple them in series and the lag and the overshoot both compound, so the tail clears the head’s mark by 12px and goes further than anything in the file was ever asked to go. The two rules are drawn over the rows rather than behind them, because what has to be readable is a leading cap crossing a line, and a rule hidden under an opaque row cannot be crossed. Watch the dwell rather than the peak: the tail is on target for a quarter of the time the head is, and is still ringing a second after the head is home. That cost is why a whip belongs on four or five items and nothing longer.

physicsliststaggerloop

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 — a chain in series</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);
      }

      * {
        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: 226px;
        height: 148px;
      }

      /* Two standing measurements: where every row rests, and where the head
         stops. Both are drawn over the rows rather than behind them, because
         what has to be readable is a row's leading cap crossing a line — and a
         rule hidden under an opaque row cannot be crossed. */
      .mark {
        position: absolute;
        top: 4px;
        height: 126px;
        z-index: 3;
      }

      .mark-home {
        left: 30px;
        width: 1.5px;
        background: repeating-linear-gradient(
          to bottom,
          color-mix(in srgb, var(--slate) 24%, transparent) 0 4px,
          transparent 4px 9px
        );
      }

      .mark-target {
        left: 74px;
        width: 2px;
        background: var(--clay-d);
        opacity: 0.45;
      }

      .stack {
        position: absolute;
        top: 12px;
        left: 0;
        right: 0;
        display: grid;
        gap: 9px;
        justify-items: start;
        padding-left: 30px;
      }

      .row {
        position: relative;
        width: 130px;
        height: 16px;
        border-radius: calc(5px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
      }

      /* The leading cap is the thing the eye actually tracks against the two
         rules. Every row is otherwise identical: coupling is the only variable
         in the file, and a second one would have to be discounted. */
      .row::before {
        content: '';
        position: absolute;
        top: 3px;
        bottom: 3px;
        left: -1.5px;
        width: 3px;
        border-radius: 2px;
        background: var(--clay);
      }

      .row::after {
        content: '';
        position: absolute;
        top: 5.5px;
        left: 12px;
        width: 74px;
        height: 3px;
        border-radius: 2px;
        background: var(--oat);
      }

      /* Only the head is held. Everything below it is moved by the row above. */
      .link-0::before {
        background: var(--clay-d);
      }

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

      .link-0 {
        animation: link-0 var(--t) infinite;
      }
      .link-1 {
        animation: link-1 var(--t) infinite;
      }
      .link-2 {
        animation: link-2 var(--t) infinite;
      }
      .link-3 {
        animation: link-3 var(--t) infinite;
      }
      .link-4 {
        animation: link-4 var(--t) infinite;
      }

      /* The head is the only row given a destination: 44px out, 44px back, and
         it never passes its own mark. */
      @keyframes link-0 {
        0%,
        10% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        18%,
        50% {
          transform: translateX(44px);
          animation-timing-function: var(--ease-out);
        }
        58%,
        100% {
          transform: translateX(0);
        }
      }

      /* Each follower is coupled to the one above it rather than to the head, so
         both the lag and the overshoot compound down the chain: 1.3% of the
         clock and roughly 40% more overshoot per link. Give all four the same
         animation-delay instead and you get a stagger — every row travelling
         the same 44px, none of them passing the mark. */
      @keyframes link-1 {
        0%,
        11.3% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        23% {
          transform: translateX(48.4px);
          animation-timing-function: var(--ease-in-out);
        }
        28.5% {
          transform: translateX(42.4px);
          animation-timing-function: var(--ease-in-out);
        }
        33% {
          transform: translateX(44.6px);
          animation-timing-function: var(--ease-in-out);
        }
        37%,
        51.3% {
          transform: translateX(44px);
          animation-timing-function: var(--ease-out);
        }
        63% {
          transform: translateX(-4.4px);
          animation-timing-function: var(--ease-in-out);
        }
        68.5% {
          transform: translateX(1.6px);
          animation-timing-function: var(--ease-in-out);
        }
        73% {
          transform: translateX(-0.6px);
          animation-timing-function: var(--ease-in-out);
        }
        77%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes link-2 {
        0%,
        12.6% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        25.9% {
          transform: translateX(50.6px);
          animation-timing-function: var(--ease-in-out);
        }
        32% {
          transform: translateX(41.6px);
          animation-timing-function: var(--ease-in-out);
        }
        37% {
          transform: translateX(45.2px);
          animation-timing-function: var(--ease-in-out);
        }
        41.5%,
        52.6% {
          transform: translateX(44px);
          animation-timing-function: var(--ease-out);
        }
        65.9% {
          transform: translateX(-6.6px);
          animation-timing-function: var(--ease-in-out);
        }
        72% {
          transform: translateX(2.4px);
          animation-timing-function: var(--ease-in-out);
        }
        77% {
          transform: translateX(-0.9px);
          animation-timing-function: var(--ease-in-out);
        }
        81.5%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes link-3 {
        0%,
        15% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        28.8% {
          transform: translateX(53.2px);
          animation-timing-function: var(--ease-in-out);
        }
        35.2% {
          transform: translateX(40.7px);
          animation-timing-function: var(--ease-in-out);
        }
        40.5% {
          transform: translateX(45.8px);
          animation-timing-function: var(--ease-in-out);
        }
        45.5%,
        55% {
          transform: translateX(44px);
          animation-timing-function: var(--ease-out);
        }
        68.8% {
          transform: translateX(-9.2px);
          animation-timing-function: var(--ease-in-out);
        }
        75.2% {
          transform: translateX(3.3px);
          animation-timing-function: var(--ease-in-out);
        }
        80.5% {
          transform: translateX(-1.2px);
          animation-timing-function: var(--ease-in-out);
        }
        85.5%,
        100% {
          transform: translateX(0);
        }
      }

      /* The tail. It clears the head's mark by 12px, dwells on target for a
         quarter of the time the head does, and is still ringing a full second
         after the head is home. */
      @keyframes link-4 {
        0%,
        17.4% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        31.7% {
          transform: translateX(56.3px);
          animation-timing-function: var(--ease-in-out);
        }
        38.4% {
          transform: translateX(39.6px);
          animation-timing-function: var(--ease-in-out);
        }
        44% {
          transform: translateX(46.4px);
          animation-timing-function: var(--ease-in-out);
        }
        49.5%,
        57.4% {
          transform: translateX(44px);
          animation-timing-function: var(--ease-out);
        }
        71.7% {
          transform: translateX(-12.3px);
          animation-timing-function: var(--ease-in-out);
        }
        78.4% {
          transform: translateX(4.4px);
          animation-timing-function: var(--ease-in-out);
        }
        84% {
          transform: translateX(-1.6px);
          animation-timing-function: var(--ease-in-out);
        }
        89.5%,
        100% {
          transform: translateX(0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* No travel. The compounding is kept as the one thing that has to
           survive: five rows responding in order, each later than the last,
           with the same 1.3%-of-the-clock spacing between them. */
        .row {
          animation: chain-rest calc(var(--t) * 1.8) ease infinite !important;
          transform: none !important;
        }

        .link-1 {
          animation-delay: calc(0.075s / var(--speed)) !important;
        }
        .link-2 {
          animation-delay: calc(0.15s / var(--speed)) !important;
        }
        .link-3 {
          animation-delay: calc(0.225s / var(--speed)) !important;
        }
        .link-4 {
          animation-delay: calc(0.3s / var(--speed)) !important;
        }

        @keyframes chain-rest {
          0%,
          14% {
            opacity: 1;
          }
          28%,
          46% {
            opacity: 0.42;
          }
          62%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="scene"
      role="img"
      aria-label="Five stacked rows: the top one is pulled sideways and each row below follows the one above it, so the lag and the overshoot grow down the stack and the bottom row swings furthest"
    >
      <span class="mark mark-home"></span>
      <span class="mark mark-target"></span>
      <div class="stack">
        <div class="row link-0"></div>
        <div class="row link-1"></div>
        <div class="row link-2"></div>
        <div class="row link-3"></div>
        <div class="row link-4"></div>
      </div>
      <div class="caption">head 44px · tail 56px</div>
    </div>
  </body>
</html>