Back to the library

Press — dragged off and cancelled

Variant: Dragged off

The one almost nobody implements: a press abandoned by sliding out of the target rather than by lifting. Four transitions play and only one is a lift — the button un-presses when the pointer crosses the edge, re-presses when it wanders back in, and un-presses again on the way out, because the pressed state tracks containment and not the clock the press started on. Leaving costs 220ms against a lift’s 190ms, since drifting out is something that happened to the press while letting go is a decision. The contact dot stays lit across the whole cancel, which is what makes the un-press legible as a cancel rather than as a release. Then the payoff: the cancelled press produces nothing at all — no flash, no shake, no apology — and the completed one produces a single 350ms wash. The absence is the feedback.

controlgestureerrorloop

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 — dragged off and cancelled</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 — 4.4s: one press abandoned by walking out of the target, one
           press completed in place. The abandoned one is first on purpose, so
           the completed one is read as the difference. */
        --cycle: 4.4s;
        --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: 140px;
      }

      .btn {
        position: absolute;
        left: calc(50% - 66px);
        top: 36px;
        width: 132px;
        height: 48px;
        animation: press var(--t) infinite;
      }

      .face {
        position: absolute;
        inset: 0;
        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);
      }

      /* Both labels occupy one box rather than being laid out beside each other,
         so the fired copy lands on the resting copy to the pixel. */
      .label {
        position: absolute;
        left: 0;
        right: 0;
        top: 50%;
        transform: translateY(-50%);
        text-align: center;
        font-size: 15px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .label.rest {
        color: var(--slate);
      }

      /* The wash sits between the two labels rather than behind both, so it
         occludes the resting word on its way up instead of the two crossfading
         through each other — there is no frame with two legible words stacked.
         The fired label rides the wash's own envelope, so the acknowledgement
         cannot half-happen: either the event went out and both change, or
         nothing did. */
      .label.sent {
        color: var(--ivory);
        opacity: 0;
        animation: fire var(--t) infinite;
      }

      .wash {
        position: absolute;
        inset: 0;
        background: var(--clay);
        opacity: 0;
        animation: fire var(--t) infinite;
      }

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

      /* Four transitions, and only one of them is a lift. The button un-presses
         when the pointer leaves the target and re-presses when it comes back —
         the pressed state tracks containment, not the clock the press started.
         Leaving takes 220ms and lifting takes 190ms, because drifting out is
         something that happened to the press and letting go is a decision. */
      @keyframes press {
        0%,
        16.82% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        18.86%,
        28.07% {
          transform: scale(0.96);
          animation-timing-function: var(--ease-out);
        }
        33.07%,
        37.45% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        39.95%,
        49.43% {
          transform: scale(0.96);
          animation-timing-function: var(--ease-out);
        }
        54.43%,
        66.82% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        68.86%,
        73.64% {
          transform: scale(0.96);
          animation-timing-function: var(--ease-out);
        }
        77.95%,
        100% {
          transform: scale(1);
        }
      }

      /* The cancelled press produces nothing at all — no flash, no shake, no
         apology — and the completed one produces a single 350ms wash. That
         difference is the whole piece: the absence is what tells the user the
         event never went out. */
      @keyframes fire {
        0%,
        73.64% {
          opacity: 0;
          animation-timing-function: var(--ease-accent);
        }
        75.68% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        81.59%,
        100% {
          opacity: 0;
        }
      }

      /* Drags are linear and approaches decelerate. A hand crossing a boundary
         is moving at its own rate and has no idea where the edge is; a hand
         arriving at a target is aiming, and aiming always eases off. */
      @keyframes travel {
        0%,
        8.64% {
          transform: translate(calc(var(--_w) * 0.097), 111px);
          animation-timing-function: var(--ease-out);
        }
        14.09%,
        24.09% {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 59px);
          animation-timing-function: linear;
        }
        30%,
        35.45% {
          transform: translate(calc(var(--_w) * 0.5 + 97px), 73px);
          animation-timing-function: linear;
        }
        40.45%,
        45% {
          transform: translate(calc(var(--_w) * 0.5 + 17px), 59px);
          animation-timing-function: linear;
        }
        50.91%,
        59.09% {
          transform: translate(calc(var(--_w) * 0.5 - 95px), 95px);
          animation-timing-function: var(--ease-out);
        }
        64.55%,
        88.18% {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 59px);
          animation-timing-function: var(--ease-out);
        }
        94.55%,
        100% {
          transform: translate(calc(var(--_w) * 0.097), 111px);
        }
      }

      /* The pointer stays down across the whole first act — through the exit,
         the re-entry and the second exit — and only lifts once it is well clear
         of the target. Watching the dot survive the un-press is what makes the
         un-press legible as a cancel rather than as a release. */
      @keyframes contact {
        0%,
        16.82% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        18.86%,
        50.91% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        52.5%,
        66.82% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        68.86%,
        73.64% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        75.23%,
        100% {
          opacity: 0;
          transform: scale(0.5);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Nothing travels and nothing flexes, but the two outcomes still differ:
           the wash fires once per cycle and only once, so the loop still says
           that one of those presses counted and the other did not. */
        .ghost {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 59px);
          animation: none;
        }

        .btn {
          animation: none;
        }

        .wash,
        .label.sent {
          animation: fire calc(var(--t) * 1.6) linear infinite;
        }

        .tip {
          animation: contact calc(var(--t) * 1.6) linear infinite;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="stage"
      role="img"
      aria-label="A pointer pressing a button, dragging off it so the press cancels with no acknowledgement, then pressing and releasing in place so the button fires"
    >
      <div class="btn">
        <div class="face">
          <span class="label rest">Send</span>
          <span class="wash"></span>
          <span class="label sent">Sent</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>