Back to the library

Weight — inertia and lag

Variant: Inertia and lag

A tray shoved 62px with three chips on it that are not welded to it. Same spring, same damper, masses of 1×, 2× and 4×: natural frequency falls as 1/√m so the heavy chip is late, and the damping ratio falls as 1/√m too so the heavy chip is also the one that overshoots. Both effects fall out of the single number, which is the useful part — apparent weight cannot be faked by adding animation-delay, because delay buys you the lateness without the overshoot and the result reads as a dropped frame. The leading edge lights only while the tray is accelerating, since that is the only interval in which the contents have any reason to fall behind.

physicsstaggertransition

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>Weight — inertia and lag</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);
        --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: 138px;
      }

      .table {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 16px;
        height: 2px;
        background: var(--oat);
      }

      .tray {
        position: absolute;
        left: 6px;
        bottom: 18px;
        width: 150px;
        display: grid;
        gap: 9px;
        /* Extra room on the leading edge: the heavy chip slides 16px back into
           it and must never touch the tray wall, or the lag reads as a collision. */
        padding: 13px 12px 13px 19px;
        border-radius: calc(9px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
        animation: shove var(--t) infinite;
      }

      /* The face the force is applied to. It lights only while the tray is
         accelerating, which is the only interval in which the contents have any
         reason to fall behind. */
      .edge {
        position: absolute;
        top: 8px;
        bottom: 8px;
        width: 3px;
        border-radius: 2px;
        background: var(--clay);
        opacity: 0;
      }

      .edge-l {
        left: -1.5px;
        animation: push-right var(--t) infinite;
      }

      .edge-r {
        right: -1.5px;
        animation: push-left var(--t) infinite;
      }

      .chip {
        height: 20px;
        display: flex;
        align-items: center;
        justify-content: flex-end;
        padding-right: 7px;
        border-radius: calc(5px * var(--round));
        background: var(--clay);
        color: var(--ivory);
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.02em;
      }

      .c1 {
        width: 46px;
        animation: lag-1 var(--t) infinite;
      }
      .c2 {
        width: 62px;
        animation: lag-2 var(--t) infinite;
      }
      .c3 {
        width: 82px;
        animation: lag-4 var(--t) infinite;
      }

      /* The tray is shoved 62px in 255ms and back again. Everything the chips do
         is measured from here — their transforms are relative to a parent that
         has already arrived. */
      @keyframes shove {
        0%,
        12% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        20.5%,
        50% {
          transform: translateX(62px);
          animation-timing-function: var(--ease-out);
        }
        58.5%,
        100% {
          transform: translateX(0);
        }
      }

      /* Same spring, same damper, four times the mass. ωn falls as 1/√m, so the
         heavy chip is late; ζ falls as 1/√m too, so it is also the one that
         overshoots. Both effects come out of the one number, which is why a
         "heavier" element cannot be faked by adding delay alone. */
      @keyframes lag-1 {
        0%,
        12% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        15.5% {
          transform: translateX(-5px);
          animation-timing-function: var(--ease-in-out);
        }
        19.5% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        22% {
          transform: translateX(1.2px);
          animation-timing-function: var(--ease-in-out);
        }
        24.5%,
        50% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        53.5% {
          transform: translateX(5px);
          animation-timing-function: var(--ease-in-out);
        }
        57.5% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        60% {
          transform: translateX(-1.2px);
          animation-timing-function: var(--ease-in-out);
        }
        62.5%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes lag-2 {
        0%,
        12% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        16.5% {
          transform: translateX(-10px);
          animation-timing-function: var(--ease-in-out);
        }
        21.5% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        25% {
          transform: translateX(2.8px);
          animation-timing-function: var(--ease-in-out);
        }
        28% {
          transform: translateX(-0.5px);
          animation-timing-function: var(--ease-in-out);
        }
        30.5%,
        50% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        54.5% {
          transform: translateX(10px);
          animation-timing-function: var(--ease-in-out);
        }
        59.5% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        63% {
          transform: translateX(-2.8px);
          animation-timing-function: var(--ease-in-out);
        }
        66% {
          transform: translateX(0.5px);
          animation-timing-function: var(--ease-in-out);
        }
        68.5%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes lag-4 {
        0%,
        12% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        18% {
          transform: translateX(-16px);
          animation-timing-function: var(--ease-in-out);
        }
        24% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        29% {
          transform: translateX(5.2px);
          animation-timing-function: var(--ease-in-out);
        }
        33.5% {
          transform: translateX(-1.6px);
          animation-timing-function: var(--ease-in-out);
        }
        37.5% {
          transform: translateX(0.4px);
          animation-timing-function: var(--ease-in-out);
        }
        40%,
        50% {
          transform: translateX(0);
          animation-timing-function: var(--ease-out);
        }
        56% {
          transform: translateX(16px);
          animation-timing-function: var(--ease-in-out);
        }
        62% {
          transform: translateX(0);
          animation-timing-function: var(--ease-in-out);
        }
        67% {
          transform: translateX(-5.2px);
          animation-timing-function: var(--ease-in-out);
        }
        71.5% {
          transform: translateX(1.6px);
          animation-timing-function: var(--ease-in-out);
        }
        75.5% {
          transform: translateX(-0.4px);
          animation-timing-function: var(--ease-in-out);
        }
        78%,
        100% {
          transform: translateX(0);
        }
      }

      @keyframes push-right {
        0%,
        11.5% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        13%,
        20.5% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        24%,
        100% {
          opacity: 0;
        }
      }

      @keyframes push-left {
        0%,
        49.5% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        51%,
        58.5% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        62%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* No travel at all. What is kept is the differential — the three chips
           still respond at three different rates, in the same order, as opacity. */
        .tray,
        .edge-l,
        .edge-r {
          animation: none;
          transform: none;
        }

        .edge {
          opacity: 0;
        }

        .chip {
          animation: rest-lag calc(var(--t) * 1.8) ease infinite !important;
          transform: none;
        }

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

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

        @keyframes rest-lag {
          0%,
          12% {
            opacity: 1;
          }
          26%,
          44% {
            opacity: 0.45;
          }
          60%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="scene"
      role="img"
      aria-label="A tray shoved sideways while three chips of different mass lag behind it and catch up, the heaviest lagging most and overshooting most"
    >
      <div class="table"></div>
      <div class="tray">
        <span class="edge edge-l"></span>
        <span class="edge edge-r"></span>
        <div class="chip c1">1×</div>
        <div class="chip c2">2×</div>
        <div class="chip c3">4×</div>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.