Back to the library

Press — hold to confirm

Variant: Hold to confirm

A destructive action gated behind 900ms of holding, and it shows both endings — the first hold is abandoned at 62% and the second is carried through, because a hold-to-confirm that only ever completes is documentation of half a control. The fill runs linear and retreats on an ease-out in 210ms: a hold is a promise about time and the fill is the only thing keeping it, so easing the run would mis-state how much of the wait is left, while letting go measures nothing and abandoning has to cost nothing. The filled face is a duplicate of the base rather than a bar beside it, so one clip edge inverts the lettering on exactly the frame it passes through, and the outcome rolls up on a two-row track instead of crossfading, so no frame has both words legible. The row goes dark for 260ms after the pointer leaves and re-arms inside it — a deletion being visibly undone is a state no confirm control has.

controlgestureloadingloop

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 — hold to confirm</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 — 5.6s, because the piece has to show both endings. The first
           hold is abandoned at 62%, the second is carried to the end; a
           hold-to-confirm that only ever completes is documentation of half a
           control. */
        --cycle: 5.6s;
        --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% - 88px);
        top: 40px;
        width: 176px;
        height: 50px;
        animation:
          press var(--t) infinite,
          rearm var(--t) infinite;
      }

      .base {
        position: absolute;
        inset: 0;
        display: grid;
        place-items: center;
        border-radius: calc(10px * var(--round));
        background: var(--paper);
        border: 1px solid color-mix(in srgb, var(--rust) 42%, var(--oat));
        color: var(--rust);
        font-size: 14px;
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      /* The filled face is a duplicate of the base, not a bar drawn beside it.
         Because one clip edge cuts both the background and the lettering, the
         word inverts on exactly the frame the fill passes through it — two
         separately timed colour changes never stay that aligned. */
      .fill {
        position: absolute;
        inset: 0;
        overflow: hidden;
        border-radius: calc(10px * var(--round));
        background: var(--rust);
        color: var(--ivory);
        font-size: 14px;
        font-weight: 600;
        letter-spacing: -0.01em;
        clip-path: inset(0 100% 0 0);
        animation: charge var(--t) infinite;
      }

      /* Two rows on one track: the outcome rolls up rather than crossfading,
         so there is no frame in which both words are legible at once. */
      .roll {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        height: 100px;
        animation: commit var(--t) infinite;
      }

      .roll span {
        display: grid;
        place-items: center;
        height: 50px;
      }

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

      /* Linear on the way out, eased on the way back. A hold is a promise about
         time and the fill is the only thing keeping it, so easing it would
         mis-state how much of the wait is left; letting go is not a measurement
         of anything, so the retreat gets to be fast and decelerate. 210ms back
         against 900ms out — abandoning has to cost nothing. */
      @keyframes charge {
        0%,
        15.45% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: linear;
        }
        25.36% {
          clip-path: inset(0 38.3% 0 0);
          animation-timing-function: var(--ease-out);
        }
        29.11%,
        39.73% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: linear;
        }
        55.8%,
        83.93% {
          clip-path: inset(0 0 0 0);
        }
        83.94%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      @keyframes commit {
        0%,
        55.8% {
          transform: translateY(0);
          animation-timing-function: var(--ease-accent);
        }
        57.5%,
        83.93% {
          transform: translateY(-50px);
        }
        83.94%,
        100% {
          transform: translateY(0);
        }
      }

      /* Only 3% of scale, held for the whole hold. A press that keeps flexing
         while the fill runs would read as the control still deciding; the button
         states its position once and then lets the fill do the talking. The 1.2%
         overshoot on commit is the single bounce in the file, and a destructive
         action landing is the one moment that earns it. */
      @keyframes press {
        0%,
        13.93% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        15.45%,
        25.36% {
          transform: scale(0.97);
          animation-timing-function: var(--ease-out);
        }
        28.75%,
        38.21% {
          transform: scale(1);
          animation-timing-function: var(--ease-accent);
        }
        39.73%,
        55.8% {
          transform: scale(0.97);
          animation-timing-function: var(--ease-out);
        }
        57.14% {
          transform: scale(1.012);
          animation-timing-function: var(--ease-out);
        }
        58.48%,
        100% {
          transform: scale(1);
        }
      }

      /* The row goes dark for 260ms after the pointer has left and re-arms
         inside that window, so the loop never shows a deletion being undone —
         which is a state no confirm control has. */
      @keyframes rearm {
        0%,
        79.64% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        83.93%,
        88.57% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        92.86%,
        100% {
          opacity: 1;
        }
      }

      @keyframes travel {
        0%,
        7.86% {
          transform: translate(calc(var(--_w) * 0.1), 110px);
          animation-timing-function: var(--ease-out);
        }
        11.96%,
        74.64% {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 64px);
          animation-timing-function: var(--ease-out);
        }
        79.64%,
        100% {
          transform: translate(calc(var(--_w) * 0.1), 110px);
        }
      }

      @keyframes contact {
        0%,
        13.93% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        15.45%,
        25.36% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        28.75%,
        38.21% {
          opacity: 0;
          transform: scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        39.73%,
        55.8% {
          opacity: 0.4;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        58.48%,
        100% {
          opacity: 0;
          transform: scale(0.5);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The pointer parks and the button stops flexing. The fill and the
           outcome stay, slowed by half again: they are not decoration here, they
           are the only report of how much of the hold has been served. */
        .ghost {
          transform: translate(calc(var(--_w) * 0.5 - 1px), 64px);
          animation: none;
        }

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

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

        .roll {
          animation: commit 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 destructive button held down: the first hold is released early and the fill retreats, the second is carried to the end and the label rolls over to Deleted"
    >
      <div class="btn">
        <div class="base">Hold to delete</div>
        <div class="fill">
          <div class="roll">
            <span>Hold to delete</span>
            <span>Deleted</span>
          </div>
        </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>