Back to the library

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.

ambientloopsvgstagger

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.