Back to the library
Bleed fill confirm
Variant: Ease-out
A save button whose confirmation state floods in from the press point as a circular clip-path wipe, then a check stroke draws and the label swaps. Reach for it when a primary action commits and you want the button itself to carry the receipt instead of a toast. The fill uses a strong ease-out so the flood covers most of its distance in the first third of the 260ms wipe — the eye is on the button at press, so the information arrives immediately and the tail is just settling.
buttonconfirmationclip-pathfeedbackstate-change
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>Bleed fill confirm — ease-out</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
(#d97757 / #b85c3e / #788c5d / #b04a3f). */
--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 — every duration in the document runs off --t. */
--cycle: 3.6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: cubic-bezier(0.23, 1, 0.32, 1); /* the fill curve — the axis of this family */
/* file-specific sub-clocks, all divided by the same --speed */
--_press: calc(140ms / var(--speed));
--_wipe: calc(260ms / var(--speed));
--_check: calc(240ms / var(--speed));
}
* { box-sizing: border-box; }
html, body { margin: 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);
-webkit-font-smoothing: antialiased;
}
.stage {
width: min(340px, 88vw);
display: grid;
justify-items: center;
gap: clamp(14px, 4vw, 20px);
padding: clamp(12px, 4vw, 20px);
}
.rule {
width: 100%;
height: 1px;
background: var(--oat);
}
.btn {
position: relative;
isolation: isolate;
width: min(230px, 76vw);
height: clamp(46px, 13vw, 52px);
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid var(--oat);
overflow: hidden;
display: grid;
place-items: center;
transform-origin: 50% 50%;
animation: press var(--t) var(--ease-out) infinite;
}
/* The flood: a circle clip growing from the press point, not from centre. */
.fill {
position: absolute;
inset: 0;
background: var(--clay-d);
clip-path: circle(0% at 26% 62%);
animation: flood var(--t) var(--ease-accent) infinite;
}
/* A hairline of the lighter accent rides just ahead of the flood edge,
so the boundary reads as absorbed rather than as a hard mask. */
.halo {
position: absolute;
inset: 0;
background: var(--clay);
clip-path: circle(0% at 26% 62%);
animation: halo var(--t) var(--ease-accent) infinite;
}
.label {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
font-size: clamp(13px, 3.6vw, 14px);
font-weight: 560;
letter-spacing: 0.01em;
}
.word {
position: absolute;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
.word.a {
color: var(--slate);
animation: wordOut var(--t) var(--ease-out) infinite;
}
.word.b {
color: var(--paper);
padding-left: 24px;
opacity: 0;
animation: wordIn var(--t) var(--ease-out) infinite;
}
.tick {
position: absolute;
left: 50%;
z-index: 2;
width: 16px;
height: 16px;
margin-left: -58px;
opacity: 0;
animation: tickShow var(--t) var(--ease-out) infinite;
}
.tick path {
fill: none;
stroke: var(--paper);
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 22;
stroke-dashoffset: 22;
animation: draw var(--t) var(--ease-out) infinite;
}
.caption {
font-size: clamp(10.5px, 2.9vw, 11.5px);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--slate);
opacity: 0.45;
}
/* 0–4% press, 4–11% flood, then hold, then reset in the last slice. */
@keyframes press {
0%, 2% { transform: scale(1); }
4.5% { transform: scale(0.978); }
11% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes flood {
0%, 4% { clip-path: circle(0% at 26% 62%); }
11.5% { clip-path: circle(130% at 26% 62%); }
88% { clip-path: circle(130% at 26% 62%); }
96%, 100% { clip-path: circle(0% at 26% 62%); }
}
@keyframes halo {
0%, 3.4% { clip-path: circle(0% at 26% 62%); }
10.4% { clip-path: circle(130% at 26% 62%); }
88% { clip-path: circle(130% at 26% 62%); }
95.4%, 100% { clip-path: circle(0% at 26% 62%); }
}
@keyframes wordOut {
0%, 3% { opacity: 1; transform: translateX(-50%) translateY(0); }
8% { opacity: 0; transform: translateX(-50%) translateY(-6px); }
90% { opacity: 0; transform: translateX(-50%) translateY(-6px); }
97%, 100% { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes wordIn {
0%, 7% { opacity: 0; transform: translateX(-50%) translateY(6px); }
14% { opacity: 1; transform: translateX(-50%) translateY(0); }
88% { opacity: 1; transform: translateX(-50%) translateY(0); }
93%, 100% { opacity: 0; transform: translateX(-50%) translateY(6px); }
}
@keyframes tickShow {
0%, 9% { opacity: 0; }
13% { opacity: 1; }
88% { opacity: 1; }
92%, 100% { opacity: 0; }
}
@keyframes draw {
0%, 10% { stroke-dashoffset: 22; }
20% { stroke-dashoffset: 0; }
88% { stroke-dashoffset: 0; }
92%, 100% { stroke-dashoffset: 22; }
}
@media (max-width: 380px) {
.stage { gap: 12px; padding: 10px; }
}
@media (prefers-reduced-motion: reduce) {
/* Keep the meaning — colour and label still change — drop the wipe and travel.
Cycle stretched ~1.9x so the state swap is a slow cross-fade, not a blink. */
:root { --t: calc(6.8s / var(--speed)); }
.btn { animation: none; }
.fill {
clip-path: circle(130% at 26% 62%);
animation: fadeFill var(--t) ease infinite;
}
.halo { display: none; }
.word.a { animation: softOut var(--t) ease infinite; }
.word.b { animation: softIn var(--t) ease infinite; }
.tick { animation: tickShow var(--t) ease infinite; }
.tick path { stroke-dashoffset: 0; animation: none; }
@keyframes fadeFill {
0%, 8% { opacity: 0; }
22%, 88% { opacity: 1; }
98%, 100% { opacity: 0; }
}
@keyframes softOut {
0%, 6% { opacity: 1; transform: translateX(-50%); }
20%, 90% { opacity: 0; transform: translateX(-50%); }
100% { opacity: 1; transform: translateX(-50%); }
}
@keyframes softIn {
0%, 10% { opacity: 0; transform: translateX(-50%); }
24%, 88% { opacity: 1; transform: translateX(-50%); }
98%, 100% { opacity: 0; transform: translateX(-50%); }
}
}
</style>
</head>
<body>
<div class="stage">
<div class="rule"></div>
<div class="btn">
<div class="fill"></div>
<div class="halo"></div>
<svg class="tick" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 8.4 L6.4 11.8 L13 4.6" />
</svg>
<div class="label">
<span class="word a">Save changes</span>
<span class="word b">Saved</span>
</div>
</div>
<div class="caption">Commit state</div>
<div class="rule"></div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.