Relief — ridge lines
Variant: Ridge lines
Stacked transects sliding upward, each one filled in paper so the near ridge occludes the one behind it — that occlusion is the entire depth cue, because strokes alone give you a mesh and a mesh has no front. The loop is arithmetic rather than hidden: two transects alternate down the stack and the group travels exactly two row pitches per cycle, so the last frame is bit-for-bit the first. One profile repeated would also be seamless and would read as corrugation; two is the smallest number that buys irregular terrain. The haze is masked onto the frame and not onto the rows, which matters more than it sounds — fade rows by index and the gradient rides up the panel with them, delivering the far distance to the near edge, the one seam the travel was built to avoid. Linear throughout, since a landscape passing a window has no reason to accelerate.
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 — ridge lines</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 — the stack travels exactly two row pitches per cycle, and the
two authored transects alternate, so the last frame is bit-for-bit
the first. Nine seconds puts a row's whole life at four and a half:
slow enough that catching one crossing the frame takes patience,
which is the only speed at which a repeating field stays unrepeating. */
--cycle: 9s;
--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);
}
/* Haze lives on the frame, not on the rows. Fade the rows individually by
index and the gradient rides up the panel with them, so the far
distance arrives at the near edge — the seam the travel was built to
avoid. Masking the viewport instead pins depth to the panel and lets
the terrain pass through it. */
.map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
-webkit-mask-image: linear-gradient(
transparent 0%,
color-mix(in srgb, var(--slate) 22%, transparent) 14%,
var(--slate) 46%,
color-mix(in srgb, var(--slate) 26%, transparent) 72%,
transparent 90%
);
mask-image: linear-gradient(
transparent 0%,
color-mix(in srgb, var(--slate) 22%, transparent) 14%,
var(--slate) 46%,
color-mix(in srgb, var(--slate) 26%, transparent) 72%,
transparent 90%
);
}
/* One transform on one group moves sixteen transects. Linear, because a
landscape sliding past a window has no reason to accelerate — an eased
drift reads as the field being dragged by something. */
.stack {
animation: travel var(--t) linear infinite;
}
@keyframes travel {
to {
transform: translateY(-52px);
}
}
/* Filled in paper so a near ridge occludes the one behind it. The
occlusion is the entire depth cue in this drawing: strokes alone
produce a mesh, and a mesh has no front. */
.skin {
fill: var(--paper);
stroke: none;
}
.crest {
fill: none;
stroke: var(--clay-d);
stroke-width: 1;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
opacity: 0.5;
}
.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) {
/* Travel is the whole piece, so under reduced motion there is nothing
to soften — it has to be replaced. The haze thickens and thins on a
27s breath instead: still a loop, still the same subject, and slow
enough that it never reads as a blink. */
.stack {
animation-name: haze;
animation-duration: calc(var(--t) * 3);
animation-timing-function: ease;
transform: none;
}
@keyframes haze {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
}
</style>
</head>
<body>
<div class="panel">
<svg
class="map"
viewBox="0 0 400 210"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<defs>
<!-- Two transects, not one. A single repeated profile is seamless but
reads as corrugation; alternating two and travelling two pitches
per cycle keeps the loop exact and the terrain irregular.
Amplitude tops out at 31 against a pitch of 26, so a summit
breaks the ridge in front of it and nothing else does — push the
ratio past about 1.2 and every peak punches through, which is how
a landscape turns into chevron wallpaper. -->
<g id="ra">
<path
class="skin"
d="M0 -3 L20 -6 L40 -4 L60 -7 L80 -12 L100 -19 L120 -28 L140 -30 L160 -22 L180 -13 L200 -7 L220 -3 L240 -2 L260 -4 L280 -7 L300 -12 L320 -20 L340 -16 L360 -8 L380 -4 L400 -2 L400 32 L0 32 Z"
/>
<polyline
class="crest"
points="0,-3 20,-6 40,-4 60,-7 80,-12 100,-19 120,-28 140,-30 160,-22 180,-13 200,-7 220,-3 240,-2 260,-4 280,-7 300,-12 320,-20 340,-16 360,-8 380,-4 400,-2"
/>
</g>
<g id="rb">
<path
class="skin"
d="M0 -5 L20 -9 L40 -13 L60 -16 L80 -12 L100 -7 L120 -3 L140 -2 L160 -5 L180 -11 L200 -18 L220 -26 L240 -31 L260 -23 L280 -14 L300 -8 L320 -4 L340 -6 L360 -9 L380 -5 L400 -2 L400 32 L0 32 Z"
/>
<polyline
class="crest"
points="0,-5 20,-9 40,-13 60,-16 80,-12 100,-7 120,-3 140,-2 160,-5 180,-11 200,-18 220,-26 240,-31 260,-23 280,-14 300,-8 320,-4 340,-6 360,-9 380,-5 400,-2"
/>
</g>
</defs>
<!-- Listed far to near: document order is paint order, and the near
ridge has to be the one that covers. -->
<g class="stack">
<use href="#ra" y="-52" />
<use href="#rb" y="-26" />
<use href="#ra" y="0" />
<use href="#rb" y="26" />
<use href="#ra" y="52" />
<use href="#rb" y="78" />
<use href="#ra" y="104" />
<use href="#rb" y="130" />
<use href="#ra" y="156" />
<use href="#rb" y="182" />
<use href="#ra" y="208" />
<use href="#rb" y="234" />
<use href="#ra" y="260" />
</g>
</svg>
<div class="copy">
<p class="head">Terrain, passing</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.