Relief — contour rings
Variant: Contour rings
A contour map of two summits, breathing. Every ring scales by the same 2.8% about the same peak, which is what a cone’s contours actually do when the cone grows — the outer intervals travel further in absolute terms because they are further out, and the spacing between lines opens and closes the way a slope steepening and easing would read on paper. Three decisions keep it a map rather than a ripple. The stroke is non-scaling, because a contour that thickens as it grows stops being a measured line and becomes a wavefront. The lag between one ring and the next is negative, so every contour is already mid-breath on the first frame: ambient motion is precisely the kind that must not appear to have started. And the innermost ring is drawn only across the crest — a summit rising past the contour interval gains a ring and one sinking loses it, which is the one detail in the piece that claims elevation instead of texture. Reach for it behind a hero or an empty state where the surface should feel geological rather than idle.
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>Relief — contour rings</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 full breath per cycle. Twelve seconds is chosen against
human breathing rather than against UI timing: at six it reads as a
thing being animated, at twelve it reads as weather. */
--cycle: 12s;
--t: calc(var(--cycle) / var(--speed));
/* The lag between one contour and the next one outward. Negative, so
every ring is already mid-breath on the first frame — a field that
starts flat and fills in has an entrance, and ambient motion is
precisely the kind that must not appear to have started. */
--_lag: calc(var(--t) / -26);
--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);
}
.map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
/* Faded across the bottom third rather than stopped above the copy: a
map that ends in a clean edge reads as a chart sitting behind text,
one that thins out reads as the surface the text is printed on. */
-webkit-mask-image: linear-gradient(var(--slate) 46%, transparent 92%);
mask-image: linear-gradient(var(--slate) 46%, transparent 92%);
}
/* Contours are drawn at a fixed screen weight, never scaled with the
geometry. Without this the swell thickens every line as it grows and
the map stops being a map — it becomes a ripple, which is a completely
different claim about what the panel is showing. */
.c {
fill: none;
stroke: var(--clay-d);
stroke-width: 1;
vector-effect: non-scaling-stroke;
opacity: 0.3;
transform-box: fill-box;
transform-origin: center;
animation: swell var(--t) var(--ease-in-out) infinite;
animation-delay: calc(var(--_i) * var(--_lag));
}
/* Every ring scales by the same factor about the same summit, which is
what a cone's contours actually do when the cone grows: the outer
intervals travel further in absolute terms because they are further
out. Amplitude is 2.8% — enough that the spacing between contours
visibly opens and closes, small enough that no line ever crosses its
neighbour. */
@keyframes swell {
0%,
100% {
transform: scale(0.972);
}
50% {
transform: scale(1.028);
}
}
/* The innermost contour is the cartographic tell. A summit that rises
past the contour interval gains a ring and one that sinks loses it, so
this one is drawn only across the crest of the breath. Everything else
in the piece could be a texture; this is the part that says elevation. */
.c.tip {
opacity: 0;
animation-name: swell-tip;
}
@keyframes swell-tip {
0%,
100% {
transform: scale(0.972);
opacity: 0;
}
32% {
opacity: 0;
}
50% {
transform: scale(1.028);
opacity: 0.46;
}
68% {
opacity: 0;
}
}
.spot {
fill: var(--clay);
opacity: 0.62;
}
.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 geometry stops and the interval keeps counting. Losing and
gaining the summit ring was the piece's actual subject, so it is the
part that survives — dropping to a still map would leave a card with
nothing running on it at all, which on iOS Low Power Mode is a common
phone state rather than a rare setting. The lag doubles with the
cycle so the rings still resolve outward instead of in unison. */
.c {
animation-name: hold;
animation-duration: calc(var(--t) * 2);
animation-delay: calc(var(--_i) * var(--_lag) * 2);
animation-timing-function: ease;
transform: none;
}
.c.tip {
animation-name: hold-tip;
}
@keyframes hold {
0%,
100% {
opacity: 0.24;
}
50% {
opacity: 0.34;
}
}
@keyframes hold-tip {
0%,
100% {
opacity: 0;
}
36% {
opacity: 0;
}
50% {
opacity: 0.4;
}
64% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div class="panel">
<svg
class="map"
viewBox="0 0 400 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<!-- Each peak is a group carrying the rotation as an SVG attribute, so
the CSS scale on the ellipses inside it never has to share a
transform property with it. -->
<g transform="rotate(-14 142 112)">
<ellipse class="c" style="--_i: 5" cx="142" cy="112" rx="104" ry="72" />
<ellipse class="c" style="--_i: 4" cx="142" cy="112" rx="83" ry="57" />
<ellipse class="c" style="--_i: 3" cx="142" cy="112" rx="62" ry="43" />
<ellipse class="c" style="--_i: 2" cx="142" cy="112" rx="42" ry="29" />
<ellipse class="c" style="--_i: 1" cx="142" cy="112" rx="23" ry="16" />
<ellipse class="c tip" style="--_i: 0" cx="142" cy="112" rx="10" ry="7" />
</g>
<g transform="rotate(21 314 62)">
<ellipse class="c" style="--_i: 4" cx="314" cy="62" rx="61" ry="43" />
<ellipse class="c" style="--_i: 3" cx="314" cy="62" rx="45" ry="32" />
<ellipse class="c" style="--_i: 2" cx="314" cy="62" rx="29" ry="21" />
<ellipse class="c" style="--_i: 1" cx="314" cy="62" rx="15" ry="11" />
<ellipse class="c tip" style="--_i: 0" cx="314" cy="62" rx="6" ry="4" />
</g>
<circle class="spot" cx="142" cy="112" r="2.2" />
<circle class="spot" cx="314" cy="62" r="2.2" />
</svg>
<div class="copy">
<p class="head">Ground that breathes</p>
<p class="sub">Ambient relief. 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.