Back to the library

Weight — drop and impact

Variant: Drop and impact

The base case for anything that should feel like an object: a slab released from 96px, landing, deforming, and settling in two shrinking bounces. Three decisions carry it. Falls accelerate and rises decelerate, so gravity gets the one characterful curve in the file and every ascent gets ease-out. Peak squash lands 39ms after contact rather than on it, because a body deforms as a consequence of having been stopped. And every scaleX is 1/√scaleY, which conserves area — a squash that also loses volume reads as the element shrinking rather than compressing. The shadow is the only thing reporting altitude, so it does the work a drop shadow normally fakes.

physicsgestureloop

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 — drop and impact</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: 2.8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* The one characterful curve here is gravity: it only accelerates.
           Every fall in the document uses it; every rise uses --ease-out. */
        --ease-accent: cubic-bezier(0.55, 0, 1, 0.45);
      }

      * {
        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 {
        position: relative;
        width: 240px;
        height: 178px;
      }

      .ground {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 24px;
        height: 2px;
        background: var(--oat);
        animation: ground-flex var(--t) infinite;
      }

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

      /* Cast, not drawn: the shadow is the only thing in the piece that reports
         altitude, so it widens and thins as the slab climbs and tightens under
         it at contact. */
      .shadow {
        position: absolute;
        left: 50%;
        bottom: 20px;
        width: 96px;
        height: 11px;
        margin-left: -48px;
        border-radius: 50%;
        background: radial-gradient(
          ellipse at center,
          color-mix(in srgb, var(--slate) 62%, transparent) 0%,
          transparent 72%
        );
        animation: cast var(--t) infinite;
      }

      .drop {
        position: absolute;
        left: 50%;
        bottom: 26px;
        width: 84px;
        margin-left: -42px;
        animation: fall var(--t) infinite;
      }

      .slab {
        position: relative;
        height: 48px;
        overflow: hidden;
        border-radius: calc(8px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
        /* Deformation happens against the surface it hits, never the centre. */
        transform-origin: center bottom;
        animation: squash var(--t) infinite;
      }

      /* The contact face. Giving the striking edge its own colour is what makes
         the squash legible at card size — a 3px band compressing to 2.5px is
         readable in a way a 48px box losing 8px of height is not. */
      .slab::after {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 3px;
        background: var(--clay);
      }

      /* 96px of fall, then rebounds of 21px and 6px — a coefficient of
         restitution near 0.22. The intervals follow it: each bounce lasts
         √(h) of the last, so they halve rather than repeating evenly, which is
         the difference between a dropped object and a metronome. */
      @keyframes fall {
        0%,
        12% {
          transform: translateY(-96px);
          animation-timing-function: var(--ease-accent);
        }
        23% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        28.1% {
          transform: translateY(-21px);
          animation-timing-function: var(--ease-accent);
        }
        33.2% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        36% {
          transform: translateY(-6px);
          animation-timing-function: var(--ease-accent);
        }
        38.7%,
        93% {
          transform: translateY(0);
        }
        94%,
        100% {
          transform: translateY(-96px);
        }
      }

      /* Volume is conserved: every scaleX is 1/√scaleY, so the slab never gains
         or loses area. The peak squash lands 39ms AFTER contact, not on it —
         a body deforms because it has already been stopped. */
      @keyframes squash {
        0%,
        20.5% {
          transform: scale(1, 1);
          animation-timing-function: var(--ease-accent);
        }
        23% {
          transform: scale(0.971, 1.06);
          animation-timing-function: var(--ease-out);
        }
        24.4% {
          transform: scale(1.091, 0.84);
          animation-timing-function: var(--ease-out);
        }
        26.3% {
          transform: scale(0.985, 1.03);
          animation-timing-function: var(--ease-out);
        }
        28.1%,
        33.2% {
          transform: scale(1, 1);
          animation-timing-function: var(--ease-out);
        }
        34.1% {
          transform: scale(1.036, 0.932);
          animation-timing-function: var(--ease-out);
        }
        35.6%,
        38.7% {
          transform: scale(1, 1);
          animation-timing-function: var(--ease-out);
        }
        39.4% {
          transform: scale(1.013, 0.975);
          animation-timing-function: var(--ease-out);
        }
        40.8%,
        100% {
          transform: scale(1, 1);
        }
      }

      @keyframes cast {
        0%,
        12% {
          transform: scaleX(1.3);
          opacity: 0.1;
          animation-timing-function: var(--ease-accent);
        }
        23% {
          transform: scaleX(0.86);
          opacity: 0.36;
          animation-timing-function: var(--ease-out);
        }
        24.4% {
          transform: scaleX(0.98);
          opacity: 0.34;
          animation-timing-function: var(--ease-out);
        }
        28.1% {
          transform: scaleX(1.1);
          opacity: 0.18;
          animation-timing-function: var(--ease-accent);
        }
        33.2% {
          transform: scaleX(0.88);
          opacity: 0.34;
          animation-timing-function: var(--ease-out);
        }
        36% {
          transform: scaleX(1);
          opacity: 0.26;
          animation-timing-function: var(--ease-accent);
        }
        38.7%,
        93% {
          transform: scaleX(0.9);
          opacity: 0.33;
        }
        94%,
        100% {
          transform: scaleX(1.3);
          opacity: 0.1;
        }
      }

      /* The floor answers. 1.5px is below the threshold of "the layout moved"
         and above the threshold of "nothing happened" — without it the slab
         reads as landing on the screen rather than on the surface drawn for it. */
      @keyframes ground-flex {
        0%,
        23% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        24.4% {
          transform: translateY(1.5px);
          animation-timing-function: var(--ease-out);
        }
        28%,
        33.2% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        34.1% {
          transform: translateY(0.7px);
          animation-timing-function: var(--ease-out);
        }
        36.5%,
        100% {
          transform: translateY(0);
        }
      }

      /* The slab is re-lifted at 94%, inside the window where the rig is already
         at zero opacity — so the loop never shows a rewind. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* A drop is pure vestibular travel, so all of it goes. What survives is
           the fact the piece is about: an object at rest on a surface, with the
           shadow still breathing so the card is not frozen. */
        .drop,
        .slab,
        .ground {
          animation: none;
          transform: none;
        }

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

        .shadow {
          animation: rest-cast calc(var(--t) * 2) ease infinite;
          transform: scaleX(0.9);
        }

        @keyframes rest-cast {
          0%,
          100% {
            opacity: 0.22;
          }
          50% {
            opacity: 0.34;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A slab dropped onto a surface, squashing on impact and settling in two shrinking bounces"
    >
      <div class="ground"></div>
      <div class="rig">
        <div class="shadow"></div>
        <div class="drop">
          <div class="slab"></div>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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