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.