Back to the library

Site — stick-slip

Variant: Stick-slip

A crate pulled through a spring by a hand that never changes pace — 70px over 3.64s, linear, because a steady pull is the experiment and an eased hand would blur which party caused the lurches. Static friction is stronger than sliding friction, so the spring loads 16px while nothing moves, the crate breaks away, runs on until the spring is down to 2px, and the floor grabs it again: four 17px slips of 156ms each against 728ms of loading between them, which is the whole texture. Each slip is a half-sine — the crate leaves from a standstill at full force and is stopped dead — and the spring’s length is the hand’s keyframes minus the crate’s, so the two cannot disagree. Clay means one thing here, load: the spring inks it as it stretches, so the breakaway is predictable a beat before it happens, and the evenly spaced chalk marks are the record. Use it to show resistance, binding, or the drag that snags on a slop threshold and then jumps to catch the finger — the same mechanism, and why the fix is taking up the slack continuously rather than all at once.

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>Site — stick-slip</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: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* A slip is half a swing of the crate on its own spring: it breaks
           away at full force from a standstill and is stopped dead again by
           friction, so it starts still and ends still. Every slip uses it. */
        --ease-accent: cubic-bezier(0.37, 0, 0.63, 1);

        --_tense: color-mix(in srgb, var(--slate) 38%, transparent);
      }

      * {
        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: 54px;
      }

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

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

      /* Chalked 3px behind the crate's back edge each time it comes to rest. Equal gaps are
         the signature of stick-slip: the crate does not drift, it moves in
         a quantum set by the gap between static and sliding friction. */
      .stop {
        position: absolute;
        top: 36px;
        width: 1.5px;
        height: 8px;
        background: color-mix(in srgb, var(--slate) 36%, transparent);
        opacity: 0;
      }

      .stop.s0 {
        left: 39px;
        animation: stop-0 var(--t) infinite;
      }

      .stop.s1 {
        left: 56px;
        animation: stop-1 var(--t) infinite;
      }

      .stop.s2 {
        left: 73px;
        animation: stop-2 var(--t) infinite;
      }

      .stop.s3 {
        left: 90px;
        animation: stop-3 var(--t) infinite;
      }

      .stop.s4 {
        left: 107px;
        animation: stop-4 var(--t) infinite;
      }

      .crate {
        position: absolute;
        left: 42px;
        top: 14px;
        width: 40px;
        height: 30px;
        animation: slide var(--t) infinite;
      }

      .box {
        position: absolute;
        inset: 0;
        overflow: hidden;
        border-radius: calc(4px * var(--round));
        background: var(--paper);
        border: 1.5px solid var(--oat);
      }

      /* The contact face — where the whole argument is happening. Kept
         neutral, so clay has one meaning in the piece: load. */
      .box::after {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        bottom: 0;
        height: 3px;
        background: color-mix(in srgb, var(--slate) 26%, transparent);
      }

      /* The spring rides the crate and stretches to the hand: its length is
         the gap between a hand that moves steadily and a crate that does
         not, which is exactly the force the floor is resisting. */
      .spring {
        position: absolute;
        left: 40px;
        top: 9px;
        width: 40px;
        height: 12px;
        overflow: visible;
        transform-origin: 0 50%;
        animation: stretch var(--t) infinite;
      }

      .spring path {
        fill: none;
        stroke-width: 1.5;
        stroke-linejoin: round;
        vector-effect: non-scaling-stroke;
        animation: tension var(--t) infinite;
      }

      .hand {
        position: absolute;
        left: 117.5px;
        top: 24.5px;
        width: 9px;
        height: 9px;
        border-radius: 50%;
        background: var(--clay-d);
        animation: pull var(--t) infinite;
      }

      /* The hand never changes pace — 70px over 3.64s, linear, because a
         steady pull is the experiment and an eased hand would muddy which
         party caused the lurches. */
      @keyframes pull {
        0%,
        6% {
          transform: translateX(0);
          animation-timing-function: linear;
        }
        76%,
        100% {
          transform: translateX(70px);
        }
      }

      /* Stick, then slip. The spring has to load 16px before static
         friction lets go; once sliding, friction drops, so the crate runs
         on until the spring is down to 2px and the floor grabs it again.
         Four slips of 17px each, 156ms apiece — fast against the 728ms of
         loading between them, which is the whole texture. */
      @keyframes slide {
        0%,
        22% {
          transform: translateX(0px);
          animation-timing-function: var(--ease-accent);
        }
        25%,
        39% {
          transform: translateX(17px);
          animation-timing-function: var(--ease-accent);
        }
        42%,
        56% {
          transform: translateX(34px);
          animation-timing-function: var(--ease-accent);
        }
        59%,
        73% {
          transform: translateX(51px);
          animation-timing-function: var(--ease-accent);
        }
        76%,
        100% {
          transform: translateX(68px);
        }
      }

      /* Spring length = hand - crate, so its keyframes are the two above
         subtracted: linear loading while stuck (the hand's curve), the
         slip's curve while sliding. Rest is 40px; 1.4 is the breakaway. */
      @keyframes stretch {
        0%,
        6% {
          transform: scaleX(1);
          animation-timing-function: linear;
        }
        22% {
          transform: scaleX(1.4);
          animation-timing-function: var(--ease-accent);
        }
        25% {
          transform: scaleX(1.05);
          animation-timing-function: linear;
        }
        39% {
          transform: scaleX(1.4);
          animation-timing-function: var(--ease-accent);
        }
        42% {
          transform: scaleX(1.05);
          animation-timing-function: linear;
        }
        56% {
          transform: scaleX(1.4);
          animation-timing-function: var(--ease-accent);
        }
        59% {
          transform: scaleX(1.05);
          animation-timing-function: linear;
        }
        73% {
          transform: scaleX(1.4);
          animation-timing-function: var(--ease-accent);
        }
        76%,
        100% {
          transform: scaleX(1.05);
        }
      }

      /* Tension inks the spring on the same stops: clay means "about to
         go", so the breakaway is predictable a beat before it happens. */
      @keyframes tension {
        0%,
        6% {
          stroke: var(--_tense);
          animation-timing-function: linear;
        }
        22% {
          stroke: var(--clay);
          animation-timing-function: var(--ease-accent);
        }
        25% {
          stroke: var(--_tense);
          animation-timing-function: linear;
        }
        39% {
          stroke: var(--clay);
          animation-timing-function: var(--ease-accent);
        }
        42% {
          stroke: var(--_tense);
          animation-timing-function: linear;
        }
        56% {
          stroke: var(--clay);
          animation-timing-function: var(--ease-accent);
        }
        59% {
          stroke: var(--_tense);
          animation-timing-function: linear;
        }
        73% {
          stroke: var(--clay);
          animation-timing-function: var(--ease-accent);
        }
        76%,
        100% {
          stroke: var(--_tense);
        }
      }

      @keyframes stop-0 {
        0%,
        5% {
          opacity: 0;
        }
        8%,
        100% {
          opacity: 1;
        }
      }

      @keyframes stop-1 {
        0%,
        25% {
          opacity: 0;
        }
        27%,
        100% {
          opacity: 1;
        }
      }

      @keyframes stop-2 {
        0%,
        42% {
          opacity: 0;
        }
        44%,
        100% {
          opacity: 1;
        }
      }

      @keyframes stop-3 {
        0%,
        59% {
          opacity: 0;
        }
        61%,
        100% {
          opacity: 1;
        }
      }

      @keyframes stop-4 {
        0%,
        76% {
          opacity: 0;
        }
        78%,
        100% {
          opacity: 1;
        }
      }

      /* Crate, spring and hand are rewound between 93% and 94%, inside the
         dark window, so nothing is ever seen sliding backwards. */
      @keyframes rig-cycle {
        0% {
          opacity: 0;
        }
        3%,
        88% {
          opacity: 1;
        }
        92%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The lurches go. What stays is their record — five evenly spaced
           stops behind a crate at rest, its spring slack — with the newest
           stop breathing, because the spacing is the finding. */
        .rig,
        .crate,
        .spring,
        .spring path,
        .hand,
        .stop {
          animation: none;
        }

        .rig,
        .stop {
          opacity: 1;
        }

        .crate {
          transform: translateX(68px);
        }

        .spring {
          transform: scaleX(1.05);
        }

        .spring path {
          stroke: var(--_tense);
        }

        .hand {
          transform: translateX(70px);
        }

        .stop.s4 {
          animation: rest-cue calc(var(--t) * 0.5) var(--ease-accent) infinite alternate;
        }

        @keyframes rest-cue {
          from {
            opacity: 0.3;
          }
          to {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A crate on a floor is pulled through a spring by a hand moving at a steady speed: the crate stays put while the spring stretches, then lurches forward and sticks again, four times, leaving evenly spaced marks where it stopped"
    >
      <p class="cap">a steady pull &middot; the crate sticks, loads, and lurches</p>
      <div class="scene">
        <div class="ground"></div>
        <div class="rig">
          <span class="stop s0"></span>
          <span class="stop s1"></span>
          <span class="stop s2"></span>
          <span class="stop s3"></span>
          <span class="stop s4"></span>
          <div class="crate">
            <span class="box"></span>
            <svg class="spring" viewBox="0 0 40 12" preserveAspectRatio="none" aria-hidden="true">
              <path d="M0 6H4L6 1.5 10 10.5 14 1.5 18 10.5 22 1.5 26 10.5 30 1.5 34 10.5 36 6H40" />
            </svg>
          </div>
          <span class="hand"></span>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

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