Back to the library

Site — the lagging level

Variant: Lagging level

Two spirit levels tipped 3°, the same thumb on the free end of each, the same rule: keep turning until the bubble reads centre. In thin fluid the bubble follows 36ms behind and one 576ms ease-in-out correction lands. In thick fluid the bubble is 216ms late to start, so by the time it crosses the marks the bar is already 2.1° past level — the bubble obeys the bar, sails on, and the hand chases it: a reversal that overshoots again, then a slow nudge from someone who has learned to wait. The tally steps 1, 2, 3 moves as each begins, and level is called 3.7s after the hand first thought it was done. The bubble is never wrong, only late — every leg ends exactly where the bar’s angle puts it, catching up on a late-starting curve and bleeding off on a long tail whose slope matches it at each crossing, so a crossing reads as one motion. Open it when an indicator a person steers by is smoothed, debounced, or animated: a slider’s preview, a volume meter, a resize readout. Damping the feedback that closes a loop does not calm the user; it makes them oscillate.

physicscontrolindicatorloop

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 — the lagging level</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: 7.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* The hand on the level: a person moving a known object between two
           angles, so it leaves gently and arrives gently. */
        --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1);
        /* The bubble in thick fluid. It is overdamped — it never overshoots
           its own target — so it catches up on a curve that starts late and
           crosses centre still moving (--_follow), then bleeds the rest off
           on a long tail (--ease-accent). The two curves meet at matching
           slopes, so the crossing is one motion, not two. */
        --_follow: cubic-bezier(0.55, 0, 0.75, 0.6);
        --ease-accent: cubic-bezier(0.2, 0.5, 0.35, 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: 14px;
        width: 240px;
      }

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

      .lane {
        display: grid;
        gap: 8px;
      }

      .label {
        position: relative;
        display: flex;
        justify-content: space-between;
        font-size: 11px;
        color: color-mix(in srgb, var(--slate) 62%, transparent);
      }

      /* The tally. Each count is its own span, cut in on the frame that
         move starts: a count is a state, so it steps rather than fades. */
      .tally {
        position: relative;
        width: 86px;
        font-variant-numeric: tabular-nums;
      }

      .tally span,
      .tally b {
        position: absolute;
        top: 0;
        opacity: 0;
      }

      .tally span {
        left: 0;
      }

      .tally b {
        right: 0;
        font-weight: 600;
        color: var(--olive);
      }

      .fast .n1 {
        animation: fast-n1 var(--t) steps(1, end) infinite;
      }

      .slow .n1 {
        animation: slow-n1 var(--t) steps(1, end) infinite;
      }

      .slow .n2 {
        animation: slow-n2 var(--t) steps(1, end) infinite;
      }

      .slow .n3 {
        animation: slow-n3 var(--t) steps(1, end) infinite;
      }

      .fast .tally b {
        animation: level-fast var(--t) infinite;
      }

      .slow .tally b {
        animation: level-slow var(--t) infinite;
      }

      .track {
        position: relative;
        height: 34px;
      }

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

      .bar {
        position: absolute;
        left: 10px;
        top: 6px;
        width: 220px;
        height: 22px;
        border-radius: calc(4px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
        transform: rotate(3deg);
      }

      .fast .bar {
        animation: hand-fast var(--t) infinite;
      }

      .slow .bar {
        animation: hand-slow var(--t) infinite;
      }

      /* The hand: a thumb on the free end, so the bar is seen to be turned
         rather than to turn. It rides the bar, so it needs no keyframes. */
      .grip {
        position: absolute;
        right: 6px;
        top: 5.5px;
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--clay-d);
      }

      .vial {
        position: absolute;
        left: 57.5px;
        top: 2.5px;
        width: 102px;
        height: 14px;
        border-radius: 7px;
        background: color-mix(in srgb, var(--clay) 14%, var(--paper));
        border: 1px solid color-mix(in srgb, var(--clay) 40%, var(--oat));
      }

      /* The two lines that frame a centred bubble, a hair outside each end
         of it, so "level" is the bubble sitting exactly between them. */
      .mark {
        position: absolute;
        top: 1px;
        width: 1.5px;
        height: 10px;
        background: color-mix(in srgb, var(--slate) 40%, transparent);
      }

      .mark.l {
        left: 37px;
      }

      .mark.r {
        left: 61.5px;
      }

      .bubble {
        position: absolute;
        left: 39px;
        top: 1.5px;
        width: 22px;
        height: 9px;
        border-radius: 5px;
        background: var(--paper);
        border: 1.5px solid var(--clay-d);
        /* Bubble offset is -10px per degree of tilt: it runs to the high end. */
        transform: translateX(-30px);
      }

      .fast .bubble {
        animation: bubble-fast var(--t) infinite;
      }

      .slow .bubble {
        animation: bubble-slow var(--t) infinite;
      }

      /* Thin fluid: one 576ms correction from 3deg to level, and the bubble
         follows 36ms behind on the same curve. Feedback this tight lets the
         hand stop where it means to. */
      @keyframes hand-fast {
        0%,
        10% {
          transform: rotate(3deg);
          animation-timing-function: var(--ease-in-out);
        }
        18%,
        100% {
          transform: rotate(0deg);
        }
      }

      @keyframes bubble-fast {
        0%,
        10.5% {
          transform: translateX(-30px);
          animation-timing-function: var(--ease-in-out);
        }
        18.5%,
        100% {
          transform: translateX(0);
        }
      }

      /* Thick fluid: same hand, same intent — keep turning until the bubble
         reads centre. But the bubble is 216ms late to start and still
         catching up, so by the time it crosses the marks the bar is already
         2.1deg past level. The bubble obeys the bar, sails on to +21px, and
         the hand chases it back: a 3deg reversal that overshoots again, then
         a slow 0.9deg nudge made by someone who has learned to wait.
         Bar: 3 -> -2.1 -> 0.9 -> 0deg. */
      @keyframes hand-slow {
        0%,
        10% {
          transform: rotate(3deg);
          animation-timing-function: var(--ease-in-out);
        }
        26%,
        42% {
          transform: rotate(-2.1deg);
          animation-timing-function: var(--ease-in-out);
        }
        54%,
        64% {
          transform: rotate(0.9deg);
          animation-timing-function: var(--ease-in-out);
        }
        70%,
        100% {
          transform: rotate(0deg);
        }
      }

      /* Bubble: -30 -> 0 (crossing; the hand stops) -> 21 -> 0 (crossing;
         the hand stops) -> -9 -> 0px. Every leg ends on the position the
         bar's angle demands, so the bubble is never wrong — only late. At
         both crossings the outgoing and incoming slopes agree to within a
         few percent, so a crossing is one motion. The last leg has nothing
         to cross, so it leaves late and lands soft on --ease-in-out. */
      @keyframes bubble-slow {
        0%,
        13% {
          transform: translateX(-30px);
          animation-timing-function: var(--_follow);
        }
        26% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        40%,
        44% {
          transform: translateX(21px);
          animation-timing-function: var(--_follow);
        }
        54% {
          transform: translateX(0);
          animation-timing-function: var(--ease-accent);
        }
        61%,
        65% {
          transform: translateX(-9px);
          animation-timing-function: var(--ease-in-out);
        }
        78%,
        100% {
          transform: translateX(0);
        }
      }

      /* Counts cut in as each move begins and clear in the dark window. */
      @keyframes fast-n1 {
        0%,
        10% {
          opacity: 0;
        }
        10.01%,
        91% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @keyframes slow-n1 {
        0%,
        10% {
          opacity: 0;
        }
        10.01%,
        42% {
          opacity: 1;
        }
        42.01%,
        100% {
          opacity: 0;
        }
      }

      @keyframes slow-n2 {
        0%,
        42% {
          opacity: 0;
        }
        42.01%,
        64% {
          opacity: 1;
        }
        64.01%,
        100% {
          opacity: 0;
        }
      }

      @keyframes slow-n3 {
        0%,
        64% {
          opacity: 0;
        }
        64.01%,
        91% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      /* "Level" is called when the bubble has settled, not when it first
         crosses — and in the slow lane that is 3.7s after the hand first
         thought it was done. */
      @keyframes level-fast {
        0%,
        19% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        22%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @keyframes level-slow {
        0%,
        78% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        81%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      /* Both bars are tipped back to 3deg between 93% and 94%, inside the
         dark window. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Rotation goes, and with it the story; what stays is the outcome
           and its cost, said in the tallies — both bars level, one reached
           in a single move and one in three. */
        .rig,
        .bar,
        .bubble,
        .tally span,
        .tally b {
          animation: none;
        }

        .rig,
        .fast .n1,
        .slow .n3,
        .tally b {
          opacity: 1;
        }

        .bar,
        .bubble {
          transform: none;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="Two spirit levels tilted by three degrees are corrected by a hand on one end. The one with a quick bubble is levelled in one smooth move. The one with a slow bubble lags, so the hand overshoots level, reverses, overshoots again, and only settles on the third move"
    >
      <p class="cap">same hand, same tilt &middot; a late bubble makes it overshoot</p>
      <div class="lane fast">
        <div class="label">
          <span>thin fluid</span>
          <span class="tally"><span class="n1">1 move</span><b>level</b></span>
        </div>
        <div class="track">
          <div class="rig">
            <div class="bar">
              <div class="vial">
                <span class="mark l"></span>
                <span class="mark r"></span>
                <span class="bubble"></span>
              </div>
              <span class="grip"></span>
            </div>
          </div>
        </div>
      </div>
      <div class="lane slow">
        <div class="label">
          <span>thick fluid</span>
          <span class="tally"
            ><span class="n1">1 move</span><span class="n2">2 moves</span
            ><span class="n3">3 moves</span><b>level</b></span
          >
        </div>
        <div class="track">
          <div class="rig">
            <div class="bar">
              <div class="vial">
                <span class="mark l"></span>
                <span class="mark r"></span>
                <span class="bubble"></span>
              </div>
              <span class="grip"></span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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