Idle — float and shadow
Variant: Float and shadow
The base case for keeping an element alive while nothing is happening to it. The card never changes size, so the entire reading of altitude comes off a shadow that widens and lightens as it rises — and because the blur is applied before the transform on the same layer, scaling that shadow softens its penumbra for free, which is the one thing a static drop shadow can never fake. Seven pixels of travel, because idle motion is looked at for minutes rather than glanced at once. The two axes run at 5.6s and 13/8 of that, and 8 and 13 share no factor, so the pair only returns to its opening arrangement every twenty-six swings — a float on a single period traces the same line forever and the eye finds it inside ten seconds. The curve is a sine rather than the library’s standard ease-in-out, which dwells at the extremes and makes the card look like it is stopping at the top of every breath.
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 — float and shadow</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 — one vertical swing. The sway runs at thirteen eighths of it,
and because 8 and 13 share no factor the two axes only return to
their opening arrangement every twenty-six swings — about two and a
half minutes. A float on one period traces the same line forever and
the eye finds it in seconds. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--_sway: calc(var(--t) * 1.625);
/* Sine in-out. The library's standard ease-in-out dwells at the
extremes, which on a float reads as the card stopping at the top of
every breath; a pendulum does not stop, it reverses. */
--ease-accent: cubic-bezier(0.45, 0, 0.55, 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(190px, 52vw, 236px);
}
/* The shadow is the only thing in the file reporting altitude — the card
itself never changes size, so without this the drift reads as the card
sliding on the page rather than lifting off it. The blur is applied
before the transform on the same element, so scaling the layer widens
its penumbra too: one composited property buys both the size and the
softness a real shadow gains with distance. */
.shadow {
position: absolute;
left: 21%;
right: 21%;
bottom: clamp(30px, 6.5vw, 40px);
height: 13px;
border-radius: 50%;
background: color-mix(in srgb, var(--slate) 32%, transparent);
filter: blur(8px);
animation: cast var(--t) var(--ease-accent) infinite alternate;
}
/* Two axes, two elements, two periods. Nesting them is what keeps each
animation a single declared property rather than a keyframe that has to
restate the other axis on every stop. */
.lane {
animation: sway var(--_sway) var(--ease-accent) infinite alternate;
}
.hover {
animation: rise var(--t) var(--ease-accent) infinite alternate;
}
.card {
display: flex;
flex-direction: column;
justify-content: center;
gap: 9px;
width: min(268px, 100%);
height: clamp(112px, 32vw, 134px);
padding: 0 clamp(16px, 4.6vw, 22px);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
}
.mark {
width: 24px;
height: 24px;
border-radius: calc(7px * var(--round));
background: var(--clay);
}
.head {
font-size: clamp(15px, 4.4vw, 18px);
font-weight: 600;
letter-spacing: -0.01em;
}
.sub {
margin-top: 2px;
font-size: clamp(11.5px, 3.3vw, 13px);
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
/* Seven pixels. Idle motion is looked at for minutes rather than glanced
at once, so the amplitude belongs under the threshold at which the eye
reports it as movement — big enough that the card is not nailed down,
small enough that nothing you are reading on it drifts out from under you. */
@keyframes rise {
from {
translate: 0 0;
}
to {
translate: 0 -7px;
}
}
@keyframes sway {
from {
translate: -3px 0;
}
to {
translate: 3px 0;
}
}
@keyframes cast {
from {
transform: scale(1);
opacity: 0.8;
}
to {
transform: scale(1.16);
opacity: 0.44;
}
}
@media (prefers-reduced-motion: reduce) {
/* The card parks and the shadow keeps breathing, twice as slowly. What
is lost is the travel; what survives is the reading that something is
off the surface rather than printed on it, which is the whole claim
the piece makes. */
.lane,
.hover {
animation: none;
translate: none;
}
.shadow {
animation-duration: calc(var(--t) * 2.4);
animation-timing-function: ease;
animation-name: cast-still;
}
@keyframes cast-still {
from {
opacity: 0.8;
}
to {
opacity: 0.52;
}
}
}
</style>
</head>
<body>
<div
class="stage"
role="img"
aria-label="A card drifting slowly above the page, its shadow widening and lightening as it rises"
>
<div class="shadow"></div>
<div class="lane">
<div class="hover">
<div class="card">
<div class="mark"></div>
<div>
<p class="head">Nothing is happening</p>
<p class="sub">Idle motion — the surface is live, not busy.</p>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.