Idle — the periodic nudge
Variant: Periodic nudge
The one in the family that is mostly nothing: 520ms of motion inside a 4.8s loop, so the control sits dead still and then, occasionally, offers itself. The rest is the parameter you tune rather than the motion — frequency of use sets it, and a control passed forty times a day wants a gap nearer a minute or none at all, which is why the gap lives in `--cycle` and rides `--speed` instead of being a literal. 150ms up, 70ms held, 300ms down, both halves decelerating: a nudge is an offer and not a reply, and an offer that snaps back reads as the interface twitching. The chevron runs the identical envelope 60ms late, which buys the follow-through and the direction off one number. The shadow is a sibling of the button rather than a child, so it stays on the surface while the button leaves it — three pixels is too small to see as travel and is only legible as a lift because something that did not move got darker underneath. Nothing changes colour anywhere in the file: colour is how a control reports state, and this one is reporting none.
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>Idle — the periodic nudge</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 — 520ms of motion inside 4.8s, an 11% duty cycle, and the rest
is the parameter you actually tune. Frequency of use sets it: a
first-run screen can nudge every five seconds, a control the user
passes forty times a day should be nearer a minute or absent. --speed
is the knob for exactly that, which is why the gap is not a literal. */
--cycle: 4.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Fast off the mark, long settle — the tug. */
--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;
display: grid;
place-items: center;
width: min(300px, calc(100% - 34px));
height: clamp(118px, 32vw, 138px);
}
/* The shadow is a sibling rather than a child, so it stays on the surface
while the button leaves it. Three pixels is too small to see as travel;
it is only legible as a lift because something that did not move got
darker underneath. */
.cast {
position: absolute;
left: calc(50% - 78px);
width: 156px;
bottom: calc(50% - 30px);
height: 12px;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 34%, transparent);
filter: blur(7px);
opacity: 0.5;
animation: deepen var(--t) infinite;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
width: 168px;
height: 48px;
border-radius: calc(12px * var(--round));
background: var(--clay);
color: var(--ivory);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
animation: nudge var(--t) infinite;
}
.chev {
display: block;
width: 15px;
height: 15px;
fill: none;
stroke: var(--ivory);
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
animation: lead var(--t) infinite;
}
/* 150ms up, 70ms held, 300ms down. Both halves decelerate — the tug is
fast off the mark and the release is slower still, because a nudge is
an offer rather than a reply, and an offer that snaps back reads as the
interface twitching. Nothing about the colour changes anywhere in the
file: colour is how a control reports state, and this one is reporting
nothing at all. */
@keyframes nudge {
0%,
14% {
translate: 0 0;
animation-timing-function: var(--ease-accent);
}
17.13%,
18.58% {
translate: 0 -3px;
animation-timing-function: var(--ease-out);
}
24.83%,
100% {
translate: 0 0;
}
}
@keyframes deepen {
0%,
14% {
opacity: 0.5;
animation-timing-function: var(--ease-accent);
}
17.13%,
18.58% {
opacity: 0.78;
animation-timing-function: var(--ease-out);
}
24.83%,
100% {
opacity: 0.5;
}
}
/* The chevron runs the identical envelope 60ms late. That lag is the
follow-through: the loosest part of an object arrives last, and here it
also happens to point the way, so one delay buys both the weight and
the direction. */
@keyframes lead {
0%,
15.25% {
translate: 0 0;
animation-timing-function: var(--ease-accent);
}
18.38%,
19.83% {
translate: 3px 0;
animation-timing-function: var(--ease-out);
}
26.08%,
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* Nothing travels and nothing lifts. The chevron keeps the beat on
opacity alone, on the same envelope at two thirds the pace: the
invitation still recurs, it just stops being a movement. */
.btn,
.cast {
animation: none;
translate: none;
}
.chev {
animation-name: lead-flat;
animation-duration: calc(var(--t) * 1.5);
animation-timing-function: ease;
translate: none;
}
@keyframes lead-flat {
0%,
15.25% {
opacity: 0.55;
}
18.38%,
19.83% {
opacity: 1;
}
26.08%,
100% {
opacity: 0.55;
}
}
}
</style>
</head>
<body>
<div
class="stage"
role="img"
aria-label="A button sitting still, lifting a few pixels once every few seconds as its chevron slides forward"
>
<div class="cast" aria-hidden="true"></div>
<div class="btn">
Continue
<svg class="chev" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg">
<path d="M5.4 3.2 L9.8 7.5 L5.4 11.8" />
</svg>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.