Back to the library

Press — ripple from the contact point

Variant: Ripple from contact

Two presses on the same button in two different places, because one ripple proves nothing — a wash that always starts in the middle is a decoration, and the only way to show it is a receipt for a specific event is to move the event. Each disc is anchored in markup at its own contact point rather than one disc being repositioned, so the origin is a property of the press instead of a number some timeline has to be trusted to carry. It travels for 620ms against a 125ms hold, which means the ripple is still crossing the button long after the finger has left: a receipt that can be cancelled by lifting is not a receipt. The press itself is only 1.5% of scale against the baseline tap’s 4%, since the ripple is already carrying the acknowledgement and two answers to one press read as a stutter. Clipped by the face rather than masked to a circle, so it takes the corners on the way past.

controlgestureloop

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>Press — ripple from the contact point</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 — 3.2s and two presses, deliberately: one ripple proves nothing.
           The set only reads as origin-aware if the same button answers two
           different contact points differently. */
        --cycle: 3.2s;
        --t: calc(var(--cycle) / var(--speed));

        --_w: min(268px, 86vw);

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-accent: cubic-bezier(0.32, 0.72, 0, 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 {
        position: relative;
        width: var(--_w);
        height: 138px;
      }

      .btn {
        position: absolute;
        left: calc(50% - 100px);
        top: 40px;
        width: 200px;
        height: 50px;
        animation: press var(--t) infinite;
      }

      /* The ripple is clipped by the face rather than masked to a circle, so it
         takes the button's corners on the way past — a wave crossing a surface,
         not a disc drawn on top of one. */
      .face {
        position: absolute;
        inset: 0;
        display: grid;
        place-items: center;
        overflow: hidden;
        border-radius: calc(10px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
        box-shadow: 0 1px 2px color-mix(in srgb, var(--slate) 8%, transparent);
      }

      .label {
        position: relative;
        font-size: 15px;
        font-weight: 600;
        letter-spacing: -0.01em;
        color: var(--slate);
      }

      /* Two discs, each parked at its own contact point. Anchoring them in
         markup rather than moving one is what makes the origin a property of the
         press instead of a value some timeline has to be trusted to carry. */
      .ripple {
        position: absolute;
        width: 380px;
        height: 380px;
        margin: -190px 0 0 -190px;
        border-radius: 50%;
        background: var(--clay);
        opacity: 0;
        transform: scale(0.05);
      }

      .ripple.a {
        left: 42px;
        top: 26px;
        animation: ripple-a var(--t) infinite;
      }

      .ripple.b {
        left: 156px;
        top: 26px;
        animation: ripple-b var(--t) infinite;
      }

      .ghost {
        position: absolute;
        left: 0;
        top: 0;
        width: 14px;
        height: 18px;
        transform: translate(calc(var(--_w) * 0.09), 108px);
        animation: travel var(--t) infinite;
      }

      .arrow {
        display: block;
        width: 100%;
        height: 100%;
        fill: var(--slate);
        stroke: var(--ivory);
        stroke-width: 1.4;
        stroke-linejoin: round;
      }

      .tip {
        position: absolute;
        left: -9px;
        top: -9px;
        width: 20px;
        height: 20px;
        border-radius: 50%;
        background: var(--clay-d);
        opacity: 0;
        transform: scale(0.5);
        animation: contact var(--t) infinite;
      }

      /* 620ms of travel against a 125ms hold: the ripple is still crossing the
         button long after the finger has gone. That is correct — a ripple is a
         receipt for an event the system already accepted, and a receipt that can
         be cancelled by lifting is not a receipt. */
      @keyframes ripple-a {
        19.38% {
          opacity: 0;
          transform: scale(0.05);
          animation-timing-function: linear;
        }
        21.25% {
          opacity: 0.3;
          transform: scale(0.3);
          animation-timing-function: linear;
        }
        30% {
          opacity: 0.28;
          transform: scale(0.8);
          animation-timing-function: var(--ease-out);
        }
        38.75% {
          opacity: 0;
          transform: scale(1);
        }
        38.76%,
        100% {
          opacity: 0;
          transform: scale(0.05);
        }
      }

      @keyframes ripple-b {
        47.5% {
          opacity: 0;
          transform: scale(0.05);
          animation-timing-function: linear;
        }
        49.38% {
          opacity: 0.3;
          transform: scale(0.3);
          animation-timing-function: linear;
        }
        58.13% {
          opacity: 0.28;
          transform: scale(0.8);
          animation-timing-function: var(--ease-out);
        }
        66.88% {
          opacity: 0;
          transform: scale(1);
        }
        66.89%,
        100% {
          opacity: 0;
          transform: scale(0.05);
        }
      }

      /* The press itself is deliberately quiet — 1.5% of scale against the
         baseline tap's 4% — because the ripple is already carrying the whole
         acknowledgement and two answers to one press read as a stutter. */
      @keyframes press {
        0%,
        19.38% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        22.03%,
        25.94% {
          transform: scale(0.985);
          animation-timing-function: var(--ease-out);
        }
        30.94%,
        47.5% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        50.16%,
        54.06% {
          transform: scale(0.985);
          animation-timing-function: var(--ease-out);
        }
        59.06%,
        100% {
          transform: scale(1);
        }
      }

      @keyframes travel {
        0%,
        8.13% {
          transform: translate(calc(var(--_w) * 0.09), 108px);
          animation-timing-function: var(--ease-out);
        }
        16.25%,
        35% {
          transform: translate(calc(var(--_w) * 0.5 - 59px), 65px);
          animation-timing-function: var(--ease-out);
        }
        44.38%,
        64.38% {
          transform: translate(calc(var(--_w) * 0.5 + 55px), 65px);
          animation-timing-function: var(--ease-out);
        }
        74.38%,
        100% {
          transform: translate(calc(var(--_w) * 0.09), 108px);
        }
      }

      @keyframes contact {
        0%,
        19.38% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        22.03%,
        25.94% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        30.94%,
        47.5% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        50.16%,
        54.06% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        59.06%,
        100% {
          opacity: 0;
          transform: scale(0.5);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The pointer parks and the button stops flexing, but the ripples stay:
           they are the only thing in the piece reporting that an event was
           received, and the contact points still differ, which is the argument. */
        .ghost {
          transform: translate(calc(var(--_w) * 0.5 - 59px), 65px);
          animation: none;
        }

        .btn {
          animation: none;
        }

        .ripple.a {
          animation: ripple-a calc(var(--t) * 1.5) linear infinite;
        }

        .ripple.b {
          animation: ripple-b calc(var(--t) * 1.5) linear infinite;
        }

        .tip {
          animation: contact calc(var(--t) * 1.5) linear infinite;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A pointer pressing a button twice in different places, each press sending a ripple out from the exact point it touched"
    >
      <div class="btn">
        <div class="face">
          <span class="ripple a"></span>
          <span class="ripple b"></span>
          <span class="label">Add to library</span>
        </div>
      </div>

      <span class="ghost">
        <span class="tip"></span>
        <svg class="arrow" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg">
          <path d="M1 1 L1 14.8 L4.5 11.6 L6.7 16.6 L9.3 15.4 L7.1 10.5 L11.8 10.3 Z" />
        </svg>
      </span>
    </div>
  </body>
</html>

The variant family

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