Back to the library

Balance — nudge and escape

Variant: Nudge and escape

Two hollows and one ball: the shallow dip holds it against a small shove — it rocks and rings back into its seat — and gives it up to a bigger one, which carries it over the crest into the valley it never leaves. The easing is the energy budget: uphill decelerates, downhill accelerates, and at the crest the ball creeps across nearly stopped — the same dwell the tipping block hits at its plumb line, because every threshold is crossed at the bottom of the speedometer. The ball rides a copy of the terrain path raised by its own radius, so it cannot drift off the ground it is obeying. Reach for it to explain local versus global minima, defaults users snap back to, or any state that costs activation energy to leave.

physicsdiagramsvgloop

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>Balance — nudge and escape</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;
        --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: 6s;
        --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);
        /* Downhill. The one curve in the file that accelerates, because the
           only thing that accelerates here is falling. */
        --ease-accent: cubic-bezier(0.55, 0, 0.88, 0.4);
      }

      * {
        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: 8px;
        width: 260px;
      }

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

      .scene {
        position: relative;
        width: 260px;
        height: 150px;
      }

      .terrain {
        position: absolute;
        inset: 0;
      }

      /* Drawn after the ground so the fill cannot dim it — a hidden threshold
         is no threshold at all. */
      .threshold {
        stroke: color-mix(in srgb, var(--slate) 30%, transparent);
        stroke-width: 1.5;
        stroke-dasharray: 4 5;
      }

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

      /* The ball rides a copy of the terrain path raised by its own radius, so
         it cannot leave the ground — there is no second set of coordinates to
         drift against the drawing. */
      .ball {
        position: absolute;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        background: var(--clay);
        offset-path: path(
          'M 8 60 C 24 60, 34 72, 50 72 C 66 72, 78 54, 94 52 C 112 50, 118 60, 132 74 C 150 94, 160 116, 186 116 C 206 116, 214 100, 224 86 C 231 76, 238 70, 250 68'
        );
        offset-rotate: 0deg;
        animation: roll var(--t) infinite;
      }

      /* Two nudges from the same seat. The first climbs partway to the crest,
         runs out, and rings down in the shallow dip — a stable state doing its
         job. The second reaches the crest with nothing to spare: the creep
         across the top is the balance point again, all speed spent, and then
         the far slope is longer than any climb out of it. Uphill decelerates
         and downhill accelerates everywhere in the file, because the easing is
         the energy budget. */
      @keyframes roll {
        0%,
        8% {
          offset-distance: 15%;
          animation-timing-function: var(--ease-out);
        }
        11.5% {
          offset-distance: 21%;
          animation-timing-function: var(--ease-in-out);
        }
        15% {
          offset-distance: 12%;
          animation-timing-function: var(--ease-in-out);
        }
        18.5% {
          offset-distance: 17%;
          animation-timing-function: var(--ease-in-out);
        }
        22% {
          offset-distance: 13.8%;
          animation-timing-function: var(--ease-in-out);
        }
        25.5% {
          offset-distance: 15.6%;
          animation-timing-function: var(--ease-in-out);
        }
        29%,
        38% {
          offset-distance: 15%;
          animation-timing-function: var(--ease-out);
        }
        44% {
          offset-distance: 32%;
          animation-timing-function: linear;
        }
        47.5% {
          offset-distance: 34%;
          animation-timing-function: var(--ease-accent);
        }
        53% {
          offset-distance: 71%;
          animation-timing-function: var(--ease-out);
        }
        56.5% {
          offset-distance: 81%;
          animation-timing-function: var(--ease-in-out);
        }
        60% {
          offset-distance: 64.5%;
          animation-timing-function: var(--ease-in-out);
        }
        63.5% {
          offset-distance: 75.5%;
          animation-timing-function: var(--ease-in-out);
        }
        67% {
          offset-distance: 68%;
          animation-timing-function: var(--ease-in-out);
        }
        70.5% {
          offset-distance: 73%;
          animation-timing-function: var(--ease-in-out);
        }
        74%,
        93% {
          offset-distance: 71%;
        }
        94%,
        100% {
          offset-distance: 15%;
        }
      }

      /* The ball is re-seated between 93% and 94%, strictly inside the dark
         window, so escaping the deep valley — the thing the piece says cannot
         happen — is never shown. Both settles ring on the same 3.5% half-
         period: a hollow is a clock too. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        86% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The travel goes; the ball rests where the story ends, in the deep
           valley, and breathes so the card is not a still. */
        .rig {
          animation: none;
          opacity: 1;
        }

        .ball {
          animation: rest-cue calc(var(--t) * 1.8) ease infinite;
          offset-distance: 71%;
        }

        @keyframes rest-cue {
          0%,
          100% {
            opacity: 0.55;
          }
          50% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A ball rests in a shallow dip beside a deeper valley; a small nudge rocks it and it settles back, a bigger one carries it over the crest, where it hesitates, then rolls down and rings to rest in the deep valley"
    >
      <p class="cap">both hollows hold the ball &middot; only depth holds it against a shove</p>
      <div class="scene">
        <svg class="terrain" viewBox="0 0 260 150" aria-hidden="true">
          <path
            d="M 8 66 C 24 66, 34 78, 50 78 C 66 78, 78 60, 94 58 C 112 56, 118 66, 132 80 C 150 100, 160 122, 186 122 C 206 122, 214 106, 224 92 C 231 82, 238 76, 250 74 L 250 150 L 8 150 Z"
            fill="color-mix(in srgb, var(--oat) 38%, transparent)"
            stroke="color-mix(in srgb, var(--slate) 45%, transparent)"
            stroke-width="1.5"
            stroke-linejoin="round"
          />
          <!-- The crest's plumb line: the threshold the first nudge cannot buy. -->
          <line class="threshold" x1="94" y1="66" x2="94" y2="128" />
        </svg>
        <div class="rig">
          <span class="ball"></span>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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