Back to the library

Press — tap and release

Variant: Tap and release

The base case for anything a finger lands on, and the one to copy before reaching for any of the other three. Down takes 90ms and up takes 190ms, and that asymmetry is the whole piece: going down is a report of contact, so any latency in it reads as the interface lagging the hand, while coming back up is a surface recovering after the hand has already gone and is allowed to take its time. Scale rather than translate, since a button pushed into the page loses elevation as it goes — the shadow and the face are one scaled layer, so size and height can never disagree, and the shadow drops to 42% on top of that because the face has moved closer to the surface casting it. 4%, not 10%: a press is felt at a glance rather than watched, and past about five percent the label starts visibly shrinking under it.

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 — tap and release</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 — 2.8s: approach, a 90ms press, a 330ms hold, a 190ms release,
           and enough rest either side that the tap reads as one event rather
           than a metronome. */
        --cycle: 2.8s;
        --t: calc(var(--cycle) / var(--speed));

        /* Everything the pointer aims at is a fraction of the stage, never a
           pixel offset, so the choreography survives a 268px card and a 230px one. */
        --_w: min(268px, 86vw);

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        /* Fast off the mark, long settle. The press-down gets it because contact
           has already happened by the time the pixels move. */
        --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: 132px;
      }

      /* Face and shadow are one scaled layer, which is the whole reason elevation
         and size can never disagree: there is no second timeline for the shadow
         to drift on. */
      .btn {
        position: absolute;
        left: calc(50% - 66px);
        top: 34px;
        width: 132px;
        height: 48px;
        animation: press var(--t) infinite;
      }

      .shadow {
        position: absolute;
        inset: 0;
        border-radius: calc(10px * var(--round));
        box-shadow: 0 3px 10px color-mix(in srgb, var(--slate) 22%, transparent);
        animation: settle var(--t) infinite;
      }

      .face {
        position: absolute;
        inset: 0;
        display: grid;
        place-items: center;
        border-radius: calc(10px * var(--round));
        background: var(--clay);
        color: var(--ivory);
        font-size: 15px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .ghost {
        position: absolute;
        left: 0;
        top: 0;
        width: 14px;
        height: 18px;
        transform: translate(calc(var(--_w) * 0.11), 104px);
        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;
      }

      /* The contact disc is centred on the arrow's tip, not its body — it marks
         the one pixel the event is reported at. Shared by every member of this
         family, so a press always looks like the same press. */
      .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;
      }

      /* 90ms down, 190ms up. Down is a report of contact and any latency in it
         reads as the interface lagging the hand; up is a surface recovering
         after the hand has already left, and is allowed to take its time. */
      @keyframes press {
        0%,
        27.14% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        30.36%,
        42.14% {
          transform: scale(0.96);
          animation-timing-function: var(--ease-out);
        }
        48.93%,
        100% {
          transform: scale(1);
        }
      }

      /* The shadow does not merely shrink with the face — it also loses more
         than half its weight, because the face has moved closer to the surface
         casting it. That second reading is what makes 4% of scale feel like depth. */
      @keyframes settle {
        0%,
        27.14% {
          opacity: 1;
          animation-timing-function: var(--ease-accent);
        }
        30.36%,
        42.14% {
          opacity: 0.42;
          animation-timing-function: var(--ease-out);
        }
        48.93%,
        100% {
          opacity: 1;
        }
      }

      @keyframes travel {
        0%,
        13.57% {
          transform: translate(calc(var(--_w) * 0.11), 104px);
          animation-timing-function: var(--ease-out);
        }
        22.14%,
        60.71% {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 57px);
          animation-timing-function: var(--ease-out);
        }
        70.71%,
        100% {
          transform: translate(calc(var(--_w) * 0.11), 104px);
        }
      }

      @keyframes contact {
        0%,
        27.14% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        30.36%,
        42.14% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        48.93%,
        100% {
          opacity: 0;
          transform: scale(0.5);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The pointer parks on the target and nothing travels or scales. What
           survives is the part that is state rather than physics: the face still
           darkens on the same beats, so the button still answers. */
        .ghost {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 57px);
          animation: none;
        }

        .btn {
          animation: none;
        }

        .shadow {
          animation: none;
        }

        .face {
          animation: press-flat calc(var(--t) * 1.6) ease infinite;
        }

        .tip {
          animation: contact-flat calc(var(--t) * 1.6) ease infinite;
        }

        @keyframes press-flat {
          0%,
          27.14% {
            background: var(--clay);
          }
          30.36%,
          42.14% {
            background: var(--clay-d);
          }
          48.93%,
          100% {
            background: var(--clay);
          }
        }

        @keyframes contact-flat {
          0%,
          27.14% {
            opacity: 0;
          }
          30.36%,
          42.14% {
            opacity: 0.4;
          }
          48.93%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A pointer arriving on a button, pressing it down for a beat, and releasing"
    >
      <div class="btn">
        <div class="shadow"></div>
        <div class="face">Save</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>