Weather — steady rain
Variant: Rain
Ten lanes of rain ending on a drawn sill, because a splash needs a surface and drops that vanish mid-air read as a glitch rather than a landing. Each splash rides the identical duration and negative delay as its own lane, so the lock is structural: it cannot fire when no drop has landed, and no speed remix can drift the pair apart. Depth is three cues kept in agreement — nearer drops are longer, darker and faster — since any one alone is noise and disagreeing they flatten the field. The fall is linear because rain arrives at terminal velocity: it is not entering the scene, the frame is picking it up mid-flight. Reach for it behind an empty state or a waiting view, where the page needs weather rather than progress.
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>Weather — rain</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 fall at the base rate. Each lane multiplies it by its own
factor between 0.71 and 1.53, none sharing a simple ratio, so the
landings scatter into a patter instead of a beat. */
--cycle: 2.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 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);
}
.panel {
position: relative;
overflow: hidden;
width: min(392px, calc(100% - 26px));
height: clamp(150px, 46vw, 214px);
border: 1px solid var(--oat);
border-radius: calc(16px * var(--round));
background: var(--paper);
}
/* The one fixed mark in the piece. A splash needs a surface to happen on,
and without the sill drawn the drops would simply vanish mid-air. */
.sill {
position: absolute;
top: 88%;
right: 0;
left: 0;
height: 1px;
background: var(--oat);
opacity: 0.9;
}
/* Each lane is 88% of the panel tall — top edge to sill — so the same
translateY percentages land every drop exactly on the line at every
preview size. The ground is built into the geometry, not timed. */
.lane {
position: absolute;
top: 0;
left: var(--_x);
width: 0;
height: 88%;
transform: translateY(var(--_y));
animation: fall calc(var(--t) * var(--_d)) linear infinite;
/* Negative, so it is already raining on the first frame. An ambient
background has no opening beat. */
animation-delay: calc(var(--t) * var(--_lag) * -1);
}
/* Linear, because rain arrives at terminal velocity: it is not entering a
scene, it is already falling when the frame picks it up. An eased drop
reads as thrown. */
@keyframes fall {
0% {
transform: translateY(-115%);
}
76% {
transform: translateY(0);
opacity: 1;
}
76.6%,
100% {
transform: translateY(0);
/* A cut, not a fade — the drop does not dissolve, it becomes the
splash. The dead quarter after it is the gap before the next drop. */
opacity: 0;
}
}
/* Depth is three cues kept in agreement — nearer drops are longer,
darker and faster. Any one alone is noise; disagreeing they flatten
the field. */
.streak {
position: absolute;
bottom: 0;
left: 0;
width: 1.5px;
height: calc(17px * var(--_s));
border-radius: 999px;
translate: -50% 0;
opacity: calc(0.28 + var(--_s) * 0.28);
background: linear-gradient(to top, var(--clay-d), transparent);
}
/* The splash rides the same duration and delay as its own lane, so the
lock is structural: it cannot fire when no drop has landed, and no
amount of speed remixing can drift the two apart. */
.splash {
position: absolute;
bottom: 0;
left: 0;
width: calc(11px * var(--_s));
height: calc(3.6px * var(--_s));
border: 1px solid var(--clay-d);
border-radius: 50%;
transform: translate(-50%, 50%) scale(0.35);
opacity: 0;
animation: splash calc(var(--t) * var(--_d)) linear infinite;
animation-delay: calc(var(--t) * var(--_lag) * -1);
}
@keyframes splash {
0%,
75.8% {
opacity: 0;
transform: translate(-50%, 50%) scale(0.35);
}
/* The pop is near-instant because an impact is; only the ring that
spreads from it gets a curve, and it decelerates the way spent
energy does. */
76.2% {
opacity: calc(0.3 + var(--_s) * 0.2);
transform: translate(-50%, 50%) scale(0.5);
animation-timing-function: var(--ease-out);
}
90%,
100% {
opacity: 0;
transform: translate(-50%, 50%) scale(1);
}
}
.copy {
position: absolute;
right: clamp(16px, 4.6vw, 24px);
bottom: clamp(14px, 4.2vw, 22px);
left: clamp(16px, 4.6vw, 24px);
}
.head {
font-size: clamp(15px, 4.4vw, 19px);
font-weight: 600;
letter-spacing: -0.01em;
}
.sub {
margin-top: 3px;
font-size: clamp(11.5px, 3.3vw, 13px);
color: color-mix(in srgb, var(--slate) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
/* The fall stops and each lane rests at its own --_y — cut the
animation without those offsets and every drop piles onto the sill.
The splashes go entirely; what survives is rain as a texture, each
streak breathing on its own period so the field never blinks as one. */
.lane {
animation: none;
}
.splash {
animation: none;
opacity: 0;
}
.streak {
animation: mist calc(var(--t) * var(--_d) * 2) var(--ease-in-out) infinite alternate;
animation-delay: calc(var(--t) * var(--_lag) * -2);
}
@keyframes mist {
0% {
opacity: 0.14;
}
100% {
opacity: 0.5;
}
}
}
</style>
</head>
<body>
<div class="panel">
<div class="sill" aria-hidden="true"></div>
<div class="field" aria-hidden="true">
<span class="lane" style="--_x: 4%; --_y: -18%; --_d: 0.78; --_lag: 0.13; --_s: 1.3">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 13%; --_y: -64%; --_d: 1.39; --_lag: 0.55; --_s: 0.75">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 22%; --_y: -35%; --_d: 0.92; --_lag: 0.82; --_s: 1.1">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 31%; --_y: -80%; --_d: 1.28; --_lag: 0.31; --_s: 0.85">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 42%; --_y: -12%; --_d: 0.71; --_lag: 0.66; --_s: 1.4">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 53%; --_y: -52%; --_d: 1.13; --_lag: 0.05; --_s: 0.95">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 62%; --_y: -71%; --_d: 0.84; --_lag: 0.44; --_s: 1.2">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 73%; --_y: -28%; --_d: 1.53; --_lag: 0.91; --_s: 0.7">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 84%; --_y: -88%; --_d: 1.06; --_lag: 0.24; --_s: 1.05">
<i class="streak"></i><i class="splash"></i>
</span>
<span class="lane" style="--_x: 94%; --_y: -45%; --_d: 1.34; --_lag: 0.72; --_s: 0.8">
<i class="streak"></i><i class="splash"></i>
</span>
</div>
<div class="copy">
<p class="head">Steady rain</p>
<p class="sub">Ambient weather. Nothing here reports state.</p>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.