Back to the library

Copy confirmation, wipe fill

Variant: Wipe fill

A clay panel wipes across the button in 220ms and leaves off the right edge rather than retreating the way it came. Because the confirmed face is a clipped duplicate of the first, background and label change colour on the same frame — separately timed colour transitions never stay that aligned. The loudest of the three; use it where copying is the point of the page.

successcontrolclip-path

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 — wipe fill</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);
      }

      .copy {
        position: relative;
        display: inline-grid;
        height: 44px;
        border: 1px solid var(--oat);
        border-radius: 10px;
        background: #fff;
        font: inherit;
        font-size: 14px;
        font-weight: 500;
        color: var(--slate);
        cursor: pointer;
        overflow: hidden;
        animation: press 4s infinite;
      }

      /* Both faces sit in the same grid cell and carry the padding themselves,
         so the button is already as wide as the longer state and the clay
         panel reaches the edges without being taken out of flow. */
      .face {
        grid-area: 1 / 1;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
        padding: 0 18px;
        white-space: nowrap;
      }

      .face svg {
        width: 16px;
        height: 16px;
        fill: none;
        stroke: currentColor;
        stroke-width: 2;
        stroke-linecap: round;
        stroke-linejoin: round;
      }

      /* The confirmed state is a second, complete copy of the button face laid
         over the first. Clipping it is the only thing that animates, so the
         label and its background change colour on exactly the same frame —
         a pair of colour transitions can never stay that perfectly in step. */
      .face.done {
        background: var(--clay);
        color: var(--ivory);
        clip-path: inset(0 100% 0 0);
        animation: wipe 4s infinite;
      }

      /* In 220ms, and it leaves off the right edge rather than retreating to
         the left: the fill keeps travelling the way it came in, so the button
         reads as passing through a state instead of undoing one. */
      @keyframes wipe {
        0%,
        12% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: var(--ease-out);
        }
        17.5%,
        68% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: var(--ease-out);
        }
        72% {
          clip-path: inset(0 0 0 100%);
        }
        72.5%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      @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;
        }

        /* No travelling edge — the panel is present the whole time and only
           its opacity changes, which keeps the colour swap doing the talking. */
        .face.done {
          clip-path: none;
          animation: wipe-flat 4s ease infinite;
        }

        @keyframes wipe-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.