Press — hold to confirm
Variant: Hold to confirm
A destructive action gated behind 900ms of holding, and it shows both endings — the first hold is abandoned at 62% and the second is carried through, because a hold-to-confirm that only ever completes is documentation of half a control. The fill runs linear and retreats on an ease-out in 210ms: a hold is a promise about time and the fill is the only thing keeping it, so easing the run would mis-state how much of the wait is left, while letting go measures nothing and abandoning has to cost nothing. The filled face is a duplicate of the base rather than a bar beside it, so one clip edge inverts the lettering on exactly the frame it passes through, and the outcome rolls up on a two-row track instead of crossfading, so no frame has both words legible. The row goes dark for 260ms after the pointer leaves and re-arms inside it — a deletion being visibly undone is a state no confirm control has.
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>Press — hold to confirm</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. */
--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 — 5.6s, because the piece has to show both endings. The first
hold is abandoned at 62%, the second is carried to the end; a
hold-to-confirm that only ever completes is documentation of half a
control. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--_w: min(268px, 86vw);
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: cubic-bezier(0.32, 0.72, 0, 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);
}
.stage {
position: relative;
width: var(--_w);
height: 140px;
}
.btn {
position: absolute;
left: calc(50% - 88px);
top: 40px;
width: 176px;
height: 50px;
animation:
press var(--t) infinite,
rearm var(--t) infinite;
}
.base {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--rust) 42%, var(--oat));
color: var(--rust);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
}
/* The filled face is a duplicate of the base, not a bar drawn beside it.
Because one clip edge cuts both the background and the lettering, the
word inverts on exactly the frame the fill passes through it — two
separately timed colour changes never stay that aligned. */
.fill {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: calc(10px * var(--round));
background: var(--rust);
color: var(--ivory);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
clip-path: inset(0 100% 0 0);
animation: charge var(--t) infinite;
}
/* Two rows on one track: the outcome rolls up rather than crossfading,
so there is no frame in which both words are legible at once. */
.roll {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100px;
animation: commit var(--t) infinite;
}
.roll span {
display: grid;
place-items: center;
height: 50px;
}
.ghost {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 18px;
transform: translate(calc(var(--_w) * 0.1), 110px);
animation: travel var(--t) infinite;
}
.arrow {
display: block;
width: 100%;
height: 100%;
fill: var(--slate);
stroke: var(--ivory);
stroke-width: 1.4;
stroke-linejoin: round;
}
.tip {
position: absolute;
left: -9px;
top: -9px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--clay-d);
opacity: 0;
transform: scale(0.5);
animation: contact var(--t) infinite;
}
/* Linear on the way out, eased on the way back. A hold is a promise about
time and the fill is the only thing keeping it, so easing it would
mis-state how much of the wait is left; letting go is not a measurement
of anything, so the retreat gets to be fast and decelerate. 210ms back
against 900ms out — abandoning has to cost nothing. */
@keyframes charge {
0%,
15.45% {
clip-path: inset(0 100% 0 0);
animation-timing-function: linear;
}
25.36% {
clip-path: inset(0 38.3% 0 0);
animation-timing-function: var(--ease-out);
}
29.11%,
39.73% {
clip-path: inset(0 100% 0 0);
animation-timing-function: linear;
}
55.8%,
83.93% {
clip-path: inset(0 0 0 0);
}
83.94%,
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes commit {
0%,
55.8% {
transform: translateY(0);
animation-timing-function: var(--ease-accent);
}
57.5%,
83.93% {
transform: translateY(-50px);
}
83.94%,
100% {
transform: translateY(0);
}
}
/* Only 3% of scale, held for the whole hold. A press that keeps flexing
while the fill runs would read as the control still deciding; the button
states its position once and then lets the fill do the talking. The 1.2%
overshoot on commit is the single bounce in the file, and a destructive
action landing is the one moment that earns it. */
@keyframes press {
0%,
13.93% {
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
15.45%,
25.36% {
transform: scale(0.97);
animation-timing-function: var(--ease-out);
}
28.75%,
38.21% {
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
39.73%,
55.8% {
transform: scale(0.97);
animation-timing-function: var(--ease-out);
}
57.14% {
transform: scale(1.012);
animation-timing-function: var(--ease-out);
}
58.48%,
100% {
transform: scale(1);
}
}
/* The row goes dark for 260ms after the pointer has left and re-arms
inside that window, so the loop never shows a deletion being undone —
which is a state no confirm control has. */
@keyframes rearm {
0%,
79.64% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
83.93%,
88.57% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
92.86%,
100% {
opacity: 1;
}
}
@keyframes travel {
0%,
7.86% {
transform: translate(calc(var(--_w) * 0.1), 110px);
animation-timing-function: var(--ease-out);
}
11.96%,
74.64% {
transform: translate(calc(var(--_w) * 0.5 - 1px), 64px);
animation-timing-function: var(--ease-out);
}
79.64%,
100% {
transform: translate(calc(var(--_w) * 0.1), 110px);
}
}
@keyframes contact {
0%,
13.93% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
15.45%,
25.36% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
28.75%,
38.21% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
39.73%,
55.8% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
58.48%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
@media (prefers-reduced-motion: reduce) {
/* The pointer parks and the button stops flexing. The fill and the
outcome stay, slowed by half again: they are not decoration here, they
are the only report of how much of the hold has been served. */
.ghost {
transform: translate(calc(var(--_w) * 0.5 - 1px), 64px);
animation: none;
}
.btn {
animation: rearm calc(var(--t) * 1.5) linear infinite;
}
.fill {
animation: charge calc(var(--t) * 1.5) linear infinite;
}
.roll {
animation: commit calc(var(--t) * 1.5) linear infinite;
}
.tip {
animation: contact calc(var(--t) * 1.5) linear infinite;
}
}
</style>
</head>
<body>
<div
class="stage"
role="img"
aria-label="A destructive button held down: the first hold is released early and the fill retreats, the second is carried to the end and the label rolls over to Deleted"
>
<div class="btn">
<div class="base">Hold to delete</div>
<div class="fill">
<div class="roll">
<span>Hold to delete</span>
<span>Deleted</span>
</div>
</div>
</div>
<span class="ghost">
<span class="tip"></span>
<svg class="arrow" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1 L1 14.8 L4.5 11.6 L6.7 16.6 L9.3 15.4 L7.1 10.5 L11.8 10.3 Z" />
</svg>
</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.