Back to the library
Copy confirmation, odometer roll
Variant: Odometer roll
Icon and label ride one track that rolls up a row in 220ms. There are three rows — Copy, Copied, Copy — so the reset rolls in the same direction instead of rewinding, and a single transform moves both halves, which is why they can never land a frame apart. The most physical of the three: it suits toolbars whose buttons already read as keys.
successcontrolsvg
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 — roll 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);
}
.copy {
display: inline-grid;
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;
overflow: hidden;
animation: press 4s infinite;
}
/* align-self: start keeps the track at its natural 3-row height inside a
44px button — stretched, its own height would be one row and every
percentage translate would come out a third of the distance. */
.track {
display: grid;
align-self: start;
animation: roll 4s infinite;
}
.row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 42px;
white-space: nowrap;
}
.row svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.row.done svg {
stroke: var(--clay);
}
/* Three rows — Copy, Copied, Copy — so the reset is a roll in the same
direction rather than a rewind; the third row is identical to the
first, which hides the jump back to 0%. One 220ms transform moves the
whole track, so icon and label can never arrive a frame apart. */
@keyframes roll {
0%,
12% {
transform: translateY(0);
animation-timing-function: var(--ease-out);
}
17.5%,
68% {
transform: translateY(-33.3333%);
animation-timing-function: var(--ease-out);
}
73%,
100% {
transform: translateY(-66.6666%);
}
}
@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;
}
/* Collapse the track into one cell: the rows now crossfade in place,
so the state still changes but nothing travels. */
.track {
animation: none;
transform: none;
}
.row {
grid-area: 1 / 1;
animation: row-out 4s ease infinite;
}
.row.done {
animation-name: row-in;
}
@keyframes row-out {
0%,
12% {
opacity: 1;
}
17.5%,
68% {
opacity: 0;
}
73%,
100% {
opacity: 1;
}
}
@keyframes row-in {
0%,
12% {
opacity: 0;
}
17.5%,
68% {
opacity: 1;
}
73%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<button class="copy" type="button">
<span class="track">
<span class="row">
<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="row 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>
<span class="row" aria-hidden="true">
<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>
</button>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.