Back to the library

Site — driven to refusal

Variant: Driven to refusal

Five identical blows on a pile and a log of what each one bought: 16, 8, 4, 2, then 1px. The toe has reached hard ground, so resistance climbs steeply and each blow buys half the last — a stylised halving, but the true shape of a driving record near refusal — and the 13% rhythm never changes, so the shrinking is the only variable on screen. The ram lifts on ease-out because it is decelerating against gravity, falls on gravity’s ease-in, and the pile stops in 96ms of ease-out because the ground stops it, not a timer; the ram rides the pile, so its keyframes only describe the lift. The pile is chalked every 8px the way it is on site: the first blow buries two marks, the last an eighth of one. Each bar in the log grows over the same 96ms its blow takes to land with its value cut in beneath it, and the call waits until the second set falls under the dashed criterion, because refusal is about two blows, not one. It is the honest shape of the planning fallacy — extrapolate from the first blow and you expect to be done after the second. Use it for diminishing-returns progress, convergence, retries that each win less, or any meter that should visibly slow before it stops.

physicsprogresschartloop

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 — driven to refusal</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: 6.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1);
        /* Gravity, and only gravity: the ram's fall accelerates all the way
           into the pile. Every lift decelerates on --ease-out instead. */
        --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 {
        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: 132px;
        overflow: hidden;
      }

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

      /* Soil is drawn over the pile, not under it: the buried length stays
         faintly visible, so the eye can still count marks going down. */
      .soil {
        position: absolute;
        left: 0;
        top: 106px;
        width: 124px;
        bottom: 0;
        /* Feathered on the right, so the ground reads as continuing under
           the log rather than as a pasted tile. */
        background: linear-gradient(
          to right,
          color-mix(in srgb, var(--oat) 72%, transparent) 75%,
          transparent
        );
        z-index: 1;
      }

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

      .lead {
        position: absolute;
        top: 0;
        width: 1.5px;
        height: 104px;
        background: var(--oat);
      }

      .lead.l {
        left: 36px;
      }

      .lead.r {
        left: 74.5px;
      }

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

      /* Marked every 4px, the way a pile is chalked on site so the blow
         count can be logged against penetration. Two marks vanish into the
         ground on the first blow; the last closes an eighth of one. */
      .pile {
        position: absolute;
        left: 49px;
        top: 50px;
        width: 14px;
        height: 100px;
        border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
        background:
          repeating-linear-gradient(
            to bottom,
            transparent 0 6.5px,
            color-mix(in srgb, var(--slate) 34%, transparent) 6.5px 8px
          ),
          var(--paper);
        border: 1.5px solid var(--oat);
        border-bottom: 0;
      }

      .ram {
        position: absolute;
        left: 41px;
        top: 30px;
        width: 30px;
        height: 20px;
        overflow: hidden;
        border-radius: calc(4px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
        animation: blows var(--t) infinite;
      }

      .ram::after {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 3px;
        background: var(--clay);
      }

      /* The log sits on the same datum as the ground: one bar per blow,
         height = set (penetration) at 3x, value printed under it. */
      .log {
        position: absolute;
        left: 136px;
        right: 8px;
        top: 0;
        height: 104px;
      }

      .log-title {
        position: absolute;
        left: 0;
        top: 12px;
        font-size: 11px;
        color: color-mix(in srgb, var(--slate) 62%, transparent);
      }

      .bar {
        position: absolute;
        bottom: 0;
        width: 12px;
        border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
        background: var(--clay);
        transform: scaleY(0);
        transform-origin: 50% 100%;
      }

      .bar.b1 {
        left: 4px;
        height: 48px;
        animation: set-1 var(--t) infinite;
      }

      .bar.b2 {
        left: 24px;
        height: 24px;
        animation: set-2 var(--t) infinite;
      }

      .bar.b3 {
        left: 44px;
        height: 12px;
        animation: set-3 var(--t) infinite;
      }

      .bar.b4 {
        left: 64px;
        height: 6px;
        background: var(--olive);
        animation: set-4 var(--t) infinite;
      }

      .bar.b5 {
        left: 84px;
        height: 3px;
        background: var(--olive);
        animation: set-5 var(--t) infinite;
      }

      .val {
        position: absolute;
        top: 108px;
        width: 12px;
        font-size: 11px;
        text-align: center;
        font-variant-numeric: tabular-nums;
        color: color-mix(in srgb, var(--slate) 62%, transparent);
        opacity: 0;
      }

      .val.v1 {
        left: 4px;
        animation: val-1 var(--t) steps(1, end) infinite;
      }

      .val.v2 {
        left: 24px;
        animation: val-2 var(--t) steps(1, end) infinite;
      }

      .val.v3 {
        left: 44px;
        animation: val-3 var(--t) steps(1, end) infinite;
      }

      .val.v4 {
        left: 64px;
        animation: val-4 var(--t) steps(1, end) infinite;
      }

      .val.v5 {
        left: 84px;
        animation: val-5 var(--t) steps(1, end) infinite;
      }

      /* The refusal criterion, set before the first blow lands: two
         consecutive sets under it and the pile is done. */
      .criterion {
        position: absolute;
        left: 0;
        right: 0;
        bottom: 7.5px;
        height: 1.5px;
        background: repeating-linear-gradient(to right, var(--olive) 0 3px, transparent 3px 6px);
      }

      .verdict {
        position: absolute;
        right: 0;
        bottom: 13px;
        font-size: 11px;
        font-weight: 600;
        color: var(--olive);
        opacity: 0;
        animation: verdict var(--t) infinite;
      }

      /* Five identical blows. Each is a 384ms lift on --ease-out (the ram is
         decelerating against gravity), a 192ms fall on gravity's curve, and
         impact at 15, 28, 41, 54 and 67% of the cycle. The ram rides the
         pile, so its keyframes only describe the lift; where the pile goes,
         it goes. */
      @keyframes blows {
        0%,
        6%,
        19%,
        32%,
        45%,
        58% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        12%,
        25%,
        38%,
        51%,
        64% {
          transform: translateY(-28px);
          animation-timing-function: var(--ease-accent);
        }
        15%,
        28%,
        41%,
        54%,
        67%,
        100% {
          transform: translateY(0);
        }
      }

      /* Sets of 16, 8, 4, 2 and 1px from the same blow. The toe has
         reached hard ground, so resistance climbs steeply and each blow
         buys half the last — a stylised halving; a real driving record is
         noisier, but this is its shape near refusal. Each set lands in 96ms
         on --ease-out — the pile is stopped by the ground, not by a timer —
         and the 13%-long rhythm never changes, which is what makes the
         shrinking legible. */
      @keyframes drive {
        0%,
        15% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        16.5%,
        28% {
          transform: translateY(16px);
          animation-timing-function: var(--ease-out);
        }
        29.5%,
        41% {
          transform: translateY(24px);
          animation-timing-function: var(--ease-out);
        }
        42.5%,
        54% {
          transform: translateY(28px);
          animation-timing-function: var(--ease-out);
        }
        55.5%,
        67% {
          transform: translateY(30px);
          animation-timing-function: var(--ease-out);
        }
        68.5%,
        100% {
          transform: translateY(31px);
        }
      }

      /* Each bar is logged on its own impact frame and grows over the same
         96ms the pile takes to stop, so the record and the event agree. */
      @keyframes set-1 {
        0%,
        15% {
          transform: scaleY(0);
          animation-timing-function: var(--ease-out);
        }
        16.5%,
        100% {
          transform: scaleY(1);
        }
      }

      @keyframes set-2 {
        0%,
        28% {
          transform: scaleY(0);
          animation-timing-function: var(--ease-out);
        }
        29.5%,
        100% {
          transform: scaleY(1);
        }
      }

      @keyframes set-3 {
        0%,
        41% {
          transform: scaleY(0);
          animation-timing-function: var(--ease-out);
        }
        42.5%,
        100% {
          transform: scaleY(1);
        }
      }

      @keyframes set-4 {
        0%,
        54% {
          transform: scaleY(0);
          animation-timing-function: var(--ease-out);
        }
        55.5%,
        100% {
          transform: scaleY(1);
        }
      }

      @keyframes set-5 {
        0%,
        67% {
          transform: scaleY(0);
          animation-timing-function: var(--ease-out);
        }
        68.5%,
        100% {
          transform: scaleY(1);
        }
      }

      @keyframes val-1 {
        0%,
        15% {
          opacity: 0;
        }
        15.01%,
        100% {
          opacity: 1;
        }
      }

      @keyframes val-2 {
        0%,
        28% {
          opacity: 0;
        }
        28.01%,
        100% {
          opacity: 1;
        }
      }

      @keyframes val-3 {
        0%,
        41% {
          opacity: 0;
        }
        41.01%,
        100% {
          opacity: 1;
        }
      }

      @keyframes val-4 {
        0%,
        54% {
          opacity: 0;
        }
        54.01%,
        100% {
          opacity: 1;
        }
      }

      @keyframes val-5 {
        0%,
        67% {
          opacity: 0;
        }
        67.01%,
        100% {
          opacity: 1;
        }
      }

      /* Called a beat after the second short set is logged, not on it —
         the criterion is about two blows, so it waits for the second to
         finish before saying so. */
      @keyframes verdict {
        0%,
        70% {
          opacity: 0;
          transform: translateY(3px);
          animation-timing-function: var(--ease-out);
        }
        74%,
        100% {
          opacity: 1;
          transform: translateY(0);
        }
      }

      /* Pile, ram and log are reset between 93% and 94%, inside the dark
         window — a pile climbing back out of the ground is never shown. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The hammering is the motion and all of it goes. What stays is the
           log — five halving bars and the line they ended under — with the
           criterion breathing, because that line is the decision. */
        .rig,
        .drive,
        .ram,
        .bar,
        .val,
        .verdict {
          animation: none;
        }

        .rig,
        .val,
        .verdict {
          opacity: 1;
        }

        .drive {
          transform: translateY(31px);
        }

        .bar {
          transform: none;
        }

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

        @keyframes rest-cue {
          from {
            opacity: 0.35;
          }
          to {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A pile driver drops the same ram on a pile five times; the pile sinks 16, 8, 4, 2 and then 1 pixel, while a log beside it draws one halving bar per blow until two bars fall under the refusal line and it reads refusal"
    >
      <p class="cap">same blow every time &middot; each buys half the last</p>
      <div class="scene">
        <div class="rig">
          <span class="lead l"></span>
          <span class="lead r"></span>
          <div class="drive">
            <span class="pile"></span>
            <span class="ram"></span>
          </div>
          <div class="log">
            <p class="log-title">set per blow</p>
            <span class="criterion"></span>
            <span class="bar b1"></span>
            <span class="bar b2"></span>
            <span class="bar b3"></span>
            <span class="bar b4"></span>
            <span class="bar b5"></span>
            <span class="val v1">16</span>
            <span class="val v2">8</span>
            <span class="val v3">4</span>
            <span class="val v4">2</span>
            <span class="val v5">1</span>
            <p class="verdict">refusal</p>
          </div>
        </div>
        <span class="soil"></span>
        <span class="ground"></span>
      </div>
    </div>
  </body>
</html>

The variant family

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