Relief — hillshade
Variant: Hillshade
The one where the terrain does not animate at all. Elevation is painted once as six hypsometric steps and left alone; the whole loop is a sun and its shade orbiting above, so the piece costs two composited transforms no matter how many contours the map carries — the cheapest way there is to make a detailed surface feel like time is passing over it. The orbit is the panel rotating about its own centre with a round blob parked off to one side, which buys a circular path for one rotation and no per-frame position maths, and the blob’s own spin is unobservable because it is round. The shade runs exactly half a revolution behind, which is physics rather than taste: the far side of a lit object is the shaded side, and any other phase puts the shadow somewhere the light could not have cast it. Twenty-four seconds, the longest loop in the family, because its whole claim is that the landscape is not the thing changing.
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 — hillshade</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 revolution of the light per cycle, so the last frame is
the first and the seam is arithmetic rather than hidden. Twenty-four
seconds is the longest loop in the family on purpose: this is the one
whose whole claim is that the terrain is not the thing changing. */
--cycle: 24s;
--t: calc(var(--cycle) / var(--speed));
}
* {
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%;
}
/* Not one element in the terrain animates. Elevation is painted once as
six hypsometric steps and left alone — the entire loop is two soft
blobs orbiting above it, which is why this costs two composited
transforms no matter how many contours the map has. */
.band {
stroke: var(--clay-d);
stroke-width: 1;
stroke-opacity: 0.2;
vector-effect: non-scaling-stroke;
}
.e1 {
fill: color-mix(in srgb, var(--oat) 22%, var(--paper));
}
.e2 {
fill: color-mix(in srgb, var(--oat) 36%, var(--paper));
}
.e3 {
fill: color-mix(in srgb, var(--oat) 50%, var(--paper));
}
.e4 {
fill: color-mix(in srgb, var(--oat) 64%, var(--paper));
}
.e5 {
fill: color-mix(in srgb, var(--oat) 78%, var(--paper));
}
.e6 {
fill: color-mix(in srgb, var(--oat) 92%, var(--paper));
}
/* The orbit is the full panel rotating about its own centre with the blob
parked off to one side, so a circular path costs one rotation and no
per-frame position maths. The blob is round, so its own spin is
unobservable. */
.orbit {
position: absolute;
inset: 0;
animation: turn var(--t) linear infinite;
}
.orbit > i {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 78%;
aspect-ratio: 1;
border-radius: 50%;
/* Offset as a share of the blob's own width, so the orbit radius scales
with the card instead of pinning the sun to a pixel distance that is
half the frame on a phone and a quarter of it on a desktop. */
transform: translate(-50%, -50%) translateX(56%);
}
/* The lit side is warm rather than white. On a hypsometric map that is
already near-paper there is no headroom above it — painting light with
light is invisible — so the sun contributes colour temperature and the
shade contributes value, and the axis between them is what the eye
actually tracks around the panel. */
.sun > i {
background: radial-gradient(
circle at 50% 50%,
color-mix(in srgb, var(--clay) 22%, transparent) 0%,
transparent 66%
);
}
.shade > i {
background: radial-gradient(
circle at 50% 50%,
color-mix(in srgb, var(--slate) 20%, transparent) 0%,
transparent 70%
);
}
/* Half a revolution behind the sun, which is not a stylistic offset —
the far side of a lit object is the shaded side, and any other phase
puts the shadow somewhere the light could not have cast it. */
.shade {
animation-delay: calc(var(--t) / -2);
}
@keyframes turn {
to {
transform: rotate(360deg);
}
}
.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 sun parks at the cartographer's north-west and the loop becomes
the light going in and out rather than round — no travel, still 36s
of something happening, and the relief stays legible because the
shade never leaves the slopes it belongs to. */
.orbit {
animation-name: dim;
animation-duration: calc(var(--t) * 1.5);
animation-timing-function: ease;
transform: rotate(-142deg);
}
.shade {
transform: rotate(38deg);
}
@keyframes dim {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 1;
}
}
}
</style>
</head>
<body>
<div class="panel">
<svg
class="map"
viewBox="0 0 400 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<g transform="rotate(-14 142 112)">
<ellipse class="band e1" cx="142" cy="112" rx="104" ry="72" />
<ellipse class="band e2" cx="142" cy="112" rx="83" ry="57" />
<ellipse class="band e3" cx="142" cy="112" rx="62" ry="43" />
<ellipse class="band e4" cx="142" cy="112" rx="42" ry="29" />
<ellipse class="band e5" cx="142" cy="112" rx="23" ry="16" />
<ellipse class="band e6" cx="142" cy="112" rx="10" ry="7" />
</g>
<g transform="rotate(21 314 62)">
<ellipse class="band e2" cx="314" cy="62" rx="61" ry="43" />
<ellipse class="band e3" cx="314" cy="62" rx="45" ry="32" />
<ellipse class="band e4" cx="314" cy="62" rx="29" ry="21" />
<ellipse class="band e5" cx="314" cy="62" rx="15" ry="11" />
<ellipse class="band e6" cx="314" cy="62" rx="6" ry="4" />
</g>
</svg>
<div class="orbit sun" aria-hidden="true"><i></i></div>
<div class="orbit shade" aria-hidden="true"><i></i></div>
<div class="copy">
<p class="head">Only the light moves</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.