Back to the library

Copy confirmation, blur swap

Variant: Blur crossfade

The quietest of the three: icon and label cross-fade to the copied state under 3px of blur, behind a 100ms press. The blur is doing real work — without it you read two overlapping words, with it you read one word becoming another. Reach for it in dense UI, beside code blocks, where the button should confirm and then get out of the way.

successcontrol

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>Copy confirmation — blur swap</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --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);
      }

      /* One 4s cycle stages the whole interaction: idle, press at 10%, swap,
         hold the acknowledgement, revert. In production this is two CSS
         transitions on a data-copied attribute — no loop, no keyframes. */
      .copy {
        display: inline-grid;
        place-items: center;
        height: 44px;
        padding: 0 18px;
        border: 1px solid var(--oat);
        border-radius: 10px;
        background: #fff;
        font: inherit;
        font-size: 14px;
        font-weight: 500;
        color: var(--slate);
        cursor: pointer;
        animation: press 4s infinite;
      }

      .face {
        grid-area: 1 / 1;
        display: flex;
        align-items: center;
        gap: 8px;
        white-space: nowrap;
      }

      /* Both faces share one grid cell, so the button is already as wide as
         "Copied" and nothing reflows mid-swap. */
      .face svg {
        width: 16px;
        height: 16px;
        fill: none;
        stroke: currentColor;
        stroke-width: 2;
        stroke-linecap: round;
        stroke-linejoin: round;
      }

      .face.idle {
        animation: face-out 4s infinite;
      }

      .face.done {
        color: var(--clay);
        animation: face-in 4s infinite;
      }

      /* 2px of blur across the 220ms crossfade — enough that the eye reads one
         word becoming another instead of two overlapping labels, and small
         enough that a 44px button is not repainting a soft edge for long. The
         filter only runs during the two swaps; it is never held.
         Coming back is not the swap in reverse. "Copied" dissipates forwards,
         blurring out past its own size, and "Copy" is repositioned while it is
         still invisible so it fades up in place — retreating back the way it
         arrived reads as un-copying. The return is 140ms against the 220ms
         entrance: the acknowledgement is announced, the tidying up is not. */
      @keyframes face-out {
        0%,
        12% {
          opacity: 1;
          filter: blur(0px);
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        17.5%,
        68% {
          opacity: 0;
          filter: blur(2px);
          transform: scale(0.98);
          animation-timing-function: var(--ease-out);
        }
        /* invisible here: the reset of the scale costs nothing to watch */
        68.5% {
          opacity: 0;
          filter: blur(2px);
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        72%,
        100% {
          opacity: 1;
          filter: blur(0px);
          transform: scale(1);
        }
      }

      @keyframes face-in {
        0%,
        12% {
          opacity: 0;
          filter: blur(2px);
          transform: scale(0.985);
          animation-timing-function: var(--ease-out);
        }
        17.5%,
        68% {
          opacity: 1;
          filter: blur(0px);
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        72%,
        100% {
          opacity: 0;
          filter: blur(2px);
          transform: scale(1.03);
        }
      }

      /* Press is 100ms down, 140ms back — the finger commits faster than the
         button recovers, and the swap starts on the way up, not the way down. */
      @keyframes press {
        0%,
        10% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        12.5% {
          transform: scale(0.97);
          animation-timing-function: var(--ease-out);
        }
        16%,
        100% {
          transform: scale(1);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .copy {
          animation: none;
        }

        .face.idle,
        .face.done {
          animation-duration: 4s;
          animation-timing-function: ease;
        }

        .face.idle {
          animation-name: face-out-flat;
        }

        .face.done {
          animation-name: face-in-flat;
        }

        /* Colour and the label change still carry the message; only the
           blur, the scale and the press are dropped. */
        @keyframes face-out-flat {
          0%,
          12% {
            opacity: 1;
          }
          17.5%,
          68% {
            opacity: 0;
          }
          72%,
          100% {
            opacity: 1;
          }
        }

        @keyframes face-in-flat {
          0%,
          12% {
            opacity: 0;
          }
          17.5%,
          68% {
            opacity: 1;
          }
          72%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <button class="copy" type="button">
      <span class="face idle">
        <svg viewBox="0 0 24 24" aria-hidden="true">
          <rect x="5" y="5" width="14" height="16" rx="2.5" />
          <rect x="9" y="2" width="6" height="4" rx="1.5" />
        </svg>
        Copy
      </span>
      <span class="face done" aria-hidden="true">
        <svg viewBox="0 0 24 24" aria-hidden="true">
          <path d="M5 12.5 10 17.5 19 7" />
        </svg>
        Copied
      </span>
    </button>
  </body>
</html>

The variant family

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