Back to the library

Site — sudden load

Variant: Sudden load

The same block on the same plank, twice: once lowered by the crane and once let go from a standstill, already touching. Both end on the dashed line at 8px; the released one gets there by way of 16. That factor of two is the textbook bound for a suddenly applied load — no drop height required — and it is the most useful fact a UI spring can teach: overshoot comes from how fast a change is applied, not how big it is. The lowered lane pays the load out over 864ms of ease-in-out, several of the plank’s own periods, so deflection only ever tracks the weight it already carries and there is nothing left over to ring. The released lane rings on a constant 144ms half-period, each swing 0.73 of the last, every swing a half-sine because that is a spring’s motion between extremes; the rust tick it leaves at 16px stays on the card after the ringing dies. One registered number drives everything — the block and thirteen plank segments laid on a parabola all read it — so the load and the board cannot disagree on any frame. Reach for it when a value jumps straight into a spring and someone asks where the bounce came from: ramp the target, or accept the 2×.

physicseasingloop

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>Site — sudden load</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.8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* The crane's lower: slow off the mark, slow onto the plank. */
        --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1);
        /* One swing of a ringing spring is half a sine wave — still at both
           extremes, fastest through the middle. Every half-period of the
           released plank uses it, and nothing else does. */
        --ease-accent: cubic-bezier(0.37, 0, 0.63, 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);
      }

      .stage {
        display: grid;
        gap: 10px;
        width: 240px;
      }

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

      .scene {
        position: relative;
        width: 240px;
        height: 128px;
      }

      .lane {
        position: absolute;
        top: 0;
        width: 112px;
        height: 128px;
        overflow: hidden;
      }

      .lane.lowered {
        left: 0;
      }

      .lane.let-go {
        left: 128px;
      }

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

      .label {
        position: absolute;
        left: 0;
        right: 0;
        top: 114px;
        font-size: 11px;
        text-align: center;
        color: color-mix(in srgb, var(--slate) 62%, transparent);
      }

      .rig {
        position: absolute;
        inset: 0 0 20px;
      }

      .trestle {
        position: absolute;
        top: 78px;
        width: 8px;
        height: 30px;
        background: var(--oat);
        border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
      }

      .trestle.l {
        left: 4px;
      }

      .trestle.r {
        left: 100px;
      }

      /* Where the plank's underside will rest once the block is fully on
         it. Both lanes end here; only one of them gets here directly. */
      .rest-mark {
        position: absolute;
        left: 42px;
        top: 86px;
        width: 28px;
        height: 1.5px;
        background: repeating-linear-gradient(
          to right,
          color-mix(in srgb, var(--slate) 34%, transparent) 0 3px,
          transparent 3px 6px
        );
      }

      /* The high-water mark: dropped in on the frame the released plank
         bottoms out and left standing, so the 2x is still on the card after
         the ringing has died away. */
      .peak-mark {
        position: absolute;
        left: 44px;
        top: 94px;
        width: 24px;
        height: 2px;
        border-radius: 1px;
        background: var(--rust);
        opacity: 0;
        animation: peak var(--t) infinite;
      }

      /* The deflection, as one number: 0 is unloaded, 1 is 16px of sag
         at midspan. Every moving part reads it, so the block and the plank
         cannot disagree on any frame. */
      @property --_k {
        syntax: '<number>';
        inherits: true;
        initial-value: 0;
      }

      /* The plank is thirteen overlapping segments laid on a parabola — the
         deflected shape of a loaded beam, which keeps its shape as it sags
         and only changes depth. Each segment drops by k * 16px * (1 - u^2)
         and turns to the curve's tangent there, u being its distance from
         midspan as a fraction of the half-span; the round ends hide the
         joints. */
      .seg {
        position: absolute;
        top: 74px;
        width: 12px;
        height: 4px;
        border-radius: 2px;
        background: var(--clay-d);
        transform: translateY(calc(var(--_k) * var(--_f) * 16px))
          rotate(calc(var(--_k) * var(--_g) * 1deg));
      }

      .seg:nth-child(1) {
        left: 2px;
        --_f: 0.148;
        --_g: 29.6;
      }

      .seg:nth-child(2) {
        left: 10px;
        --_f: 0.408;
        --_g: 25.3;
      }

      .seg:nth-child(3) {
        left: 18px;
        --_f: 0.621;
        --_g: 20.7;
      }

      .seg:nth-child(4) {
        left: 26px;
        --_f: 0.787;
        --_g: 15.9;
      }

      .seg:nth-child(5) {
        left: 34px;
        --_f: 0.905;
        --_g: 10.7;
      }

      .seg:nth-child(6) {
        left: 42px;
        --_f: 0.976;
        --_g: 5.4;
      }

      .seg:nth-child(7) {
        left: 50px;
        --_f: 1;
        --_g: -0;
      }

      .seg:nth-child(8) {
        left: 58px;
        --_f: 0.976;
        --_g: -5.4;
      }

      .seg:nth-child(9) {
        left: 66px;
        --_f: 0.905;
        --_g: -10.7;
      }

      .seg:nth-child(10) {
        left: 74px;
        --_f: 0.787;
        --_g: -15.9;
      }

      .seg:nth-child(11) {
        left: 82px;
        --_f: 0.621;
        --_g: -20.7;
      }

      .seg:nth-child(12) {
        left: 90px;
        --_f: 0.408;
        --_g: -25.3;
      }

      .seg:nth-child(13) {
        left: 98px;
        --_f: 0.148;
        --_g: -29.6;
      }

      .lowered .rig {
        animation:
          rig-cycle var(--t) linear infinite,
          lower var(--t) infinite;
      }

      .let-go .rig {
        animation:
          rig-cycle var(--t) linear infinite,
          ring var(--t) infinite;
      }

      .load {
        position: absolute;
        left: 43px;
        top: 0;
        width: 26px;
        height: 74px;
        transform: translateY(calc(var(--_k) * 16px));
      }

      .block {
        position: absolute;
        left: 0;
        bottom: 0;
        width: 26px;
        height: 24px;
        border-radius: calc(4px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
      }

      .cable {
        position: absolute;
        left: 12.25px;
        top: -24px;
        width: 1.5px;
        height: 74px;
        background: color-mix(in srgb, var(--slate) 46%, transparent);
      }

      /* Let go means the hook lets go: the cable whips back up out of the
         way on the release frame and the block is on its own. */
      .let-go .cable {
        animation: release var(--t) infinite;
      }

      /* Lowered: the crane pays the load out over 864ms — several of the
         plank's own periods — so the plank only ever feels the weight it
         already has, and deflection tracks the load with nothing left over
         to overshoot. Midpoint: 0 -> 8px, or k 0 -> 0.5. */
      @keyframes lower {
        0%,
        14% {
          --_k: 0;
          animation-timing-function: var(--ease-in-out);
        }
        32%,
        100% {
          --_k: 0.5;
        }
      }

      /* Let go: the same block, already touching the plank, released from
         zero height. The whole weight arrives at once, nothing is holding
         the plank at 8px, and it swings through to 16 — twice the static
         sag, the textbook bound for a suddenly applied load (the first
         swing is drawn undamped; the losses start after it). Then it rings
         on a constant 144ms half-period, each swing 0.73 of the last,
         around the line it would have settled on anyway:
         0 -> 16, 2.2, 12.2, 4.8, 10.2, 6.4, 9.2, 7.4, 8px, written as
         fractions of the 16px full scale. */
      @keyframes ring {
        0%,
        40% {
          --_k: 0;
          animation-timing-function: var(--ease-accent);
        }
        43% {
          --_k: 1;
          animation-timing-function: var(--ease-accent);
        }
        46% {
          --_k: 0.1375;
          animation-timing-function: var(--ease-accent);
        }
        49% {
          --_k: 0.7625;
          animation-timing-function: var(--ease-accent);
        }
        52% {
          --_k: 0.3;
          animation-timing-function: var(--ease-accent);
        }
        55% {
          --_k: 0.6375;
          animation-timing-function: var(--ease-accent);
        }
        58% {
          --_k: 0.4;
          animation-timing-function: var(--ease-accent);
        }
        61% {
          --_k: 0.575;
          animation-timing-function: var(--ease-accent);
        }
        64% {
          --_k: 0.4625;
          animation-timing-function: var(--ease-accent);
        }
        67%,
        100% {
          --_k: 0.5;
        }
      }

      /* The block rides the cable until 40%, so the cable's own keyframes
         stay at zero until then and only have to describe the retract. */
      @keyframes release {
        0%,
        40% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        45%,
        100% {
          transform: translateY(-22px);
        }
      }

      @keyframes peak {
        0%,
        42.9% {
          opacity: 0;
          transform: scaleX(0.4);
        }
        43% {
          opacity: 1;
          transform: scaleX(0.4);
          animation-timing-function: var(--ease-out);
        }
        46%,
        100% {
          opacity: 1;
          transform: scaleX(1);
        }
      }

      /* Both lanes are reset to their unloaded pose between 93% and 94%,
         inside the dark window — a load lifting itself is never shown. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The ringing is the vestibular part and all of it goes. What stays
           is the result: both planks at the same sag, and the rust mark
           showing how far the released one went to get there — breathing,
           so the eye is pointed at the number that matters. */
        .lowered .rig,
        .let-go .rig,
        .cable {
          animation: none;
        }

        .rig {
          opacity: 1;
          --_k: 0.5;
        }

        .let-go .cable {
          transform: translateY(-22px);
        }

        .peak-mark {
          animation: rest-cue calc(var(--t) * 0.6) var(--ease-in-out) infinite alternate;
        }

        @keyframes rest-cue {
          from {
            opacity: 0.4;
          }
          to {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="Two identical blocks resting on identical planks: one is lowered slowly by a cable and the plank sags smoothly to its resting depth; the other is released all at once, the plank swings through to twice that depth and rings down to the same rest"
    >
      <p class="cap">same block, same plank &middot; let go at once, it sags twice as far</p>
      <div class="scene">
        <div class="lane lowered">
          <div class="ground"></div>
          <div class="rig">
            <span class="trestle l"></span>
            <span class="trestle r"></span>
            <span class="rest-mark"></span>
            <div class="plank">
              <span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span>
            </div>
            <div class="load">
              <span class="cable"></span>
              <span class="block"></span>
            </div>
          </div>
          <p class="label">lowered</p>
        </div>
        <div class="lane let-go">
          <div class="ground"></div>
          <div class="rig">
            <span class="trestle l"></span>
            <span class="trestle r"></span>
            <span class="rest-mark"></span>
            <span class="peak-mark"></span>
            <div class="plank">
              <span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span><span class="seg"></span><span class="seg"></span
              ><span class="seg"></span>
            </div>
            <div class="load">
              <span class="cable"></span>
              <span class="block"></span>
            </div>
          </div>
          <p class="label">let go</p>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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