Press — tap and release
Variant: Tap and release
The base case for anything a finger lands on, and the one to copy before reaching for any of the other three. Down takes 90ms and up takes 190ms, and that asymmetry is the whole piece: going down is a report of contact, so any latency in it reads as the interface lagging the hand, while coming back up is a surface recovering after the hand has already gone and is allowed to take its time. Scale rather than translate, since a button pushed into the page loses elevation as it goes — the shadow and the face are one scaled layer, so size and height can never disagree, and the shadow drops to 42% on top of that because the face has moved closer to the surface casting it. 4%, not 10%: a press is felt at a glance rather than watched, and past about five percent the label starts visibly shrinking under it.
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 — tap and release</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 — 2.8s: approach, a 90ms press, a 330ms hold, a 190ms release,
and enough rest either side that the tap reads as one event rather
than a metronome. */
--cycle: 2.8s;
--t: calc(var(--cycle) / var(--speed));
/* Everything the pointer aims at is a fraction of the stage, never a
pixel offset, so the choreography survives a 268px card and a 230px one. */
--_w: min(268px, 86vw);
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Fast off the mark, long settle. The press-down gets it because contact
has already happened by the time the pixels move. */
--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: 132px;
}
/* Face and shadow are one scaled layer, which is the whole reason elevation
and size can never disagree: there is no second timeline for the shadow
to drift on. */
.btn {
position: absolute;
left: calc(50% - 66px);
top: 34px;
width: 132px;
height: 48px;
animation: press var(--t) infinite;
}
.shadow {
position: absolute;
inset: 0;
border-radius: calc(10px * var(--round));
box-shadow: 0 3px 10px color-mix(in srgb, var(--slate) 22%, transparent);
animation: settle var(--t) infinite;
}
.face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: calc(10px * var(--round));
background: var(--clay);
color: var(--ivory);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.ghost {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 18px;
transform: translate(calc(var(--_w) * 0.11), 104px);
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;
}
/* The contact disc is centred on the arrow's tip, not its body — it marks
the one pixel the event is reported at. Shared by every member of this
family, so a press always looks like the same press. */
.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;
}
/* 90ms down, 190ms up. Down is a report of contact and any latency in it
reads as the interface lagging the hand; up is a surface recovering
after the hand has already left, and is allowed to take its time. */
@keyframes press {
0%,
27.14% {
transform: scale(1);
animation-timing-function: var(--ease-accent);
}
30.36%,
42.14% {
transform: scale(0.96);
animation-timing-function: var(--ease-out);
}
48.93%,
100% {
transform: scale(1);
}
}
/* The shadow does not merely shrink with the face — it also loses more
than half its weight, because the face has moved closer to the surface
casting it. That second reading is what makes 4% of scale feel like depth. */
@keyframes settle {
0%,
27.14% {
opacity: 1;
animation-timing-function: var(--ease-accent);
}
30.36%,
42.14% {
opacity: 0.42;
animation-timing-function: var(--ease-out);
}
48.93%,
100% {
opacity: 1;
}
}
@keyframes travel {
0%,
13.57% {
transform: translate(calc(var(--_w) * 0.11), 104px);
animation-timing-function: var(--ease-out);
}
22.14%,
60.71% {
transform: translate(calc(var(--_w) * 0.5 - 1px), 57px);
animation-timing-function: var(--ease-out);
}
70.71%,
100% {
transform: translate(calc(var(--_w) * 0.11), 104px);
}
}
@keyframes contact {
0%,
27.14% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
30.36%,
42.14% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
48.93%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
@media (prefers-reduced-motion: reduce) {
/* The pointer parks on the target and nothing travels or scales. What
survives is the part that is state rather than physics: the face still
darkens on the same beats, so the button still answers. */
.ghost {
transform: translate(calc(var(--_w) * 0.5 - 1px), 57px);
animation: none;
}
.btn {
animation: none;
}
.shadow {
animation: none;
}
.face {
animation: press-flat calc(var(--t) * 1.6) ease infinite;
}
.tip {
animation: contact-flat calc(var(--t) * 1.6) ease infinite;
}
@keyframes press-flat {
0%,
27.14% {
background: var(--clay);
}
30.36%,
42.14% {
background: var(--clay-d);
}
48.93%,
100% {
background: var(--clay);
}
}
@keyframes contact-flat {
0%,
27.14% {
opacity: 0;
}
30.36%,
42.14% {
opacity: 0.4;
}
48.93%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="stage"
role="img"
aria-label="A pointer arriving on a button, pressing it down for a beat, and releasing"
>
<div class="btn">
<div class="shadow"></div>
<div class="face">Save</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.