Back to the library

Weight — rubber band

Variant: Rubber band

Overscroll: a list dragged past its top boundary, where the resistance lives entirely in the numbers rather than in a curve. Four equal slices of finger travel buy 12px, 8px, 5.5px and 3.5px of content, and every segment is linear — easing here would claim the hand slowed down, when what actually happened is the band got harder to stretch. The finger covers 56px while the content covers 29, and that visible divergence is the whole effect. On release the content relaxes home in 336ms and stops dead at rest: a rubber band returns to its own length and does not carry the content past it, which is the one place in this set where an overshoot would be wrong.

physicsgesturelist

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 — rubber band</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: 3.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 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);
      }

      /* The under-surface. It is only ever seen in the gap the pull opens, which
         is what makes the boundary legible without drawing a boundary. */
      .viewport {
        position: relative;
        width: 190px;
        height: 132px;
        overflow: hidden;
        border-radius: calc(10px * var(--round));
        background: var(--oat);
        border: 1.5px solid var(--oat);
      }

      .content {
        position: absolute;
        inset: 0;
        display: grid;
        gap: 8px;
        align-content: start;
        padding: 12px 12px 0;
        background: var(--paper);
        animation: pull var(--t) infinite;
      }

      .row {
        height: 20px;
        border-radius: calc(5px * var(--round));
        background: color-mix(in srgb, var(--slate) 9%, transparent);
      }

      .row:nth-child(2) {
        width: 74%;
      }
      .row:nth-child(4) {
        width: 86%;
      }

      /* Tension, not a scrollbar: opacity tracks how hard the band is being
         stretched, so it is brightest at the moment the travel has almost
         stopped paying out. */
      .tension {
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        height: 3px;
        background: var(--clay);
        opacity: 0;
        animation: tension var(--t) infinite;
      }

      .finger {
        position: absolute;
        left: 50%;
        top: 24px;
        width: 24px;
        height: 24px;
        margin-left: -12px;
        border-radius: 50%;
        background: color-mix(in srgb, var(--slate) 15%, transparent);
        border: 1.5px solid color-mix(in srgb, var(--slate) 32%, transparent);
        opacity: 0;
        animation:
          drag var(--t) infinite,
          contact var(--t) infinite;
      }

      /* The resistance is in the numbers, not the curve. Four equal 80ms slices
         of finger travel buy 12px, 8px, 5.5px and 3.5px of content — every
         segment is linear, because the finger is moving at a constant rate and
         easing here would claim the hand slowed down. */
      @keyframes pull {
        0%,
        10% {
          transform: translateY(0);
          animation-timing-function: linear;
        }
        12.5% {
          transform: translateY(12px);
          animation-timing-function: linear;
        }
        15% {
          transform: translateY(20px);
          animation-timing-function: linear;
        }
        17.5% {
          transform: translateY(25.5px);
          animation-timing-function: linear;
        }
        20%,
        26% {
          transform: translateY(29px);
          animation-timing-function: var(--ease-out);
        }
        36.5%,
        100% {
          transform: translateY(0);
        }
      }

      /* 56px of finger against 29px of content. The divergence is the piece —
         the disc slides visibly across the rows it is supposedly holding. */
      @keyframes drag {
        0%,
        10% {
          transform: translateY(0);
          animation-timing-function: linear;
        }
        20%,
        93% {
          transform: translateY(56px);
        }
        94%,
        100% {
          transform: translateY(0);
        }
      }

      @keyframes contact {
        0%,
        7% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        9%,
        26% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        29%,
        100% {
          opacity: 0;
        }
      }

      @keyframes tension {
        0%,
        10% {
          opacity: 0;
          animation-timing-function: linear;
        }
        20%,
        26% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        33%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Overscroll is entirely travel, so the travel goes and the rhythm
           stays: grab, tension rising, release — as opacity on the two things
           that were reporting it anyway. */
        .content {
          animation: none;
          transform: none;
        }

        .finger {
          animation: contact calc(var(--t) * 1.6) ease infinite;
          transform: translateY(30px);
        }

        .tension {
          animation: rest-tension calc(var(--t) * 1.6) ease infinite;
        }

        @keyframes rest-tension {
          0%,
          10% {
            opacity: 0;
          }
          24%,
          30% {
            opacity: 0.7;
          }
          42%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="viewport"
      role="img"
      aria-label="A list dragged past its top boundary: the content travels less and less as the finger keeps going, then relaxes back to rest on release"
    >
      <div class="content">
        <div class="row"></div>
        <div class="row"></div>
        <div class="row"></div>
        <div class="row"></div>
        <div class="row"></div>
      </div>
      <div class="tension"></div>
      <div class="finger"></div>
    </div>
  </body>
</html>

The variant family

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