Press — ripple from the contact point
Variant: Ripple from contact
Two presses on the same button in two different places, because one ripple proves nothing — a wash that always starts in the middle is a decoration, and the only way to show it is a receipt for a specific event is to move the event. Each disc is anchored in markup at its own contact point rather than one disc being repositioned, so the origin is a property of the press instead of a number some timeline has to be trusted to carry. It travels for 620ms against a 125ms hold, which means the ripple is still crossing the button long after the finger has left: a receipt that can be cancelled by lifting is not a receipt. The press itself is only 1.5% of scale against the baseline tap’s 4%, since the ripple is already carrying the acknowledgement and two answers to one press read as a stutter. Clipped by the face rather than masked to a circle, so it takes the corners on the way past.
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 — ripple from the contact point</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 — 3.2s and two presses, deliberately: one ripple proves nothing.
The set only reads as origin-aware if the same button answers two
different contact points differently. */
--cycle: 3.2s;
--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: 138px;
}
.btn {
position: absolute;
left: calc(50% - 100px);
top: 40px;
width: 200px;
height: 50px;
animation: press var(--t) infinite;
}
/* The ripple is clipped by the face rather than masked to a circle, so it
takes the button's corners on the way past — a wave crossing a surface,
not a disc drawn on top of one. */
.face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
border-radius: calc(10px * var(--round));
background: var(--paper);
border: 1px solid var(--oat);
box-shadow: 0 1px 2px color-mix(in srgb, var(--slate) 8%, transparent);
}
.label {
position: relative;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--slate);
}
/* Two discs, each parked at its own contact point. Anchoring them in
markup rather than moving one is what makes the origin a property of the
press instead of a value some timeline has to be trusted to carry. */
.ripple {
position: absolute;
width: 380px;
height: 380px;
margin: -190px 0 0 -190px;
border-radius: 50%;
background: var(--clay);
opacity: 0;
transform: scale(0.05);
}
.ripple.a {
left: 42px;
top: 26px;
animation: ripple-a var(--t) infinite;
}
.ripple.b {
left: 156px;
top: 26px;
animation: ripple-b var(--t) infinite;
}
.ghost {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 18px;
transform: translate(calc(var(--_w) * 0.09), 108px);
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;
}
/* 620ms of travel against a 125ms hold: the ripple is still crossing the
button long after the finger has gone. That is correct — a ripple is a
receipt for an event the system already accepted, and a receipt that can
be cancelled by lifting is not a receipt. */
@keyframes ripple-a {
19.38% {
opacity: 0;
transform: scale(0.05);
animation-timing-function: linear;
}
21.25% {
opacity: 0.3;
transform: scale(0.3);
animation-timing-function: linear;
}
30% {
opacity: 0.28;
transform: scale(0.8);
animation-timing-function: var(--ease-out);
}
38.75% {
opacity: 0;
transform: scale(1);
}
38.76%,
100% {
opacity: 0;
transform: scale(0.05);
}
}
@keyframes ripple-b {
47.5% {
opacity: 0;
transform: scale(0.05);
animation-timing-function: linear;
}
49.38% {
opacity: 0.3;
transform: scale(0.3);
animation-timing-function: linear;
}
58.13% {
opacity: 0.28;
transform: scale(0.8);
animation-timing-function: var(--ease-out);
}
66.88% {
opacity: 0;
transform: scale(1);
}
66.89%,
100% {
opacity: 0;
transform: scale(0.05);
}
}
/* The press itself is deliberately quiet — 1.5% of scale against the
baseline tap's 4% — because the ripple is already carrying the whole
acknowledgement and two answers to one press read as a stutter. */
@keyframes press {
0%,
19.38% {
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
22.03%,
25.94% {
transform: scale(0.985);
animation-timing-function: var(--ease-out);
}
30.94%,
47.5% {
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
50.16%,
54.06% {
transform: scale(0.985);
animation-timing-function: var(--ease-out);
}
59.06%,
100% {
transform: scale(1);
}
}
@keyframes travel {
0%,
8.13% {
transform: translate(calc(var(--_w) * 0.09), 108px);
animation-timing-function: var(--ease-out);
}
16.25%,
35% {
transform: translate(calc(var(--_w) * 0.5 - 59px), 65px);
animation-timing-function: var(--ease-out);
}
44.38%,
64.38% {
transform: translate(calc(var(--_w) * 0.5 + 55px), 65px);
animation-timing-function: var(--ease-out);
}
74.38%,
100% {
transform: translate(calc(var(--_w) * 0.09), 108px);
}
}
@keyframes contact {
0%,
19.38% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
22.03%,
25.94% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
30.94%,
47.5% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
50.16%,
54.06% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
59.06%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
@media (prefers-reduced-motion: reduce) {
/* The pointer parks and the button stops flexing, but the ripples stay:
they are the only thing in the piece reporting that an event was
received, and the contact points still differ, which is the argument. */
.ghost {
transform: translate(calc(var(--_w) * 0.5 - 59px), 65px);
animation: none;
}
.btn {
animation: none;
}
.ripple.a {
animation: ripple-a calc(var(--t) * 1.5) linear infinite;
}
.ripple.b {
animation: ripple-b 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 pointer pressing a button twice in different places, each press sending a ripple out from the exact point it touched"
>
<div class="btn">
<div class="face">
<span class="ripple a"></span>
<span class="ripple b"></span>
<span class="label">Add to library</span>
</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.