Back to the library

Relief — tide line

Variant: Tide line

Two swells and a water level on three clocks that all close together: the near swell rolls one wavelength every quarter cycle, the far one every half, and the level turns once. Whole traversals are what let independent loops share a seam. The ratio between the two swells is doing the depth — four traversals against two reads as distance rather than as speed, which is how a two-layer sea gets a third dimension without one. The waves are linear and the tide is ease-in-out, and that split is the argument: a wave crossing a window does not slow halfway, while a tide is genuinely slowest at high and low water. Three fixed rules sit behind the water because a slow level change is otherwise invisible — with nothing to be measured against, the eye reads the surface as flat and only the swell as moving, and fourteen seconds of rise goes unnoticed.

ambientloopsvg

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 — tide line</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 level is the master and the waves are its divisors: the
           near swell rolls one wavelength every quarter cycle, the far one
           every half. Both land on whole traversals at 14s, which is what lets
           two independent loops close on the same frame with no seam. */
        --cycle: 14s;
        --t: calc(var(--cycle) / var(--speed));

        --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%;
      }

      /* Three rules the water crosses. Without something fixed to be measured
         against, a slow level change is invisible — the eye reads the surface
         as flat and only the waves as moving. The marks are what turn fourteen
         seconds of drift into a rise. */
      .rule {
        stroke: var(--oat);
        stroke-width: 1;
        vector-effect: non-scaling-stroke;
        opacity: 0.85;
      }

      /* The tide is one transform on the parent of both swells, so the level
         cannot drift out of agreement with the water it is carrying. Ease-in-out
         because a tide turns: it is slowest at high and low water, which is the
         one place in the piece where an eased curve is the physical answer
         rather than a stylistic one. */
      .tide {
        animation: level var(--t) var(--ease-in-out) infinite;
      }

      @keyframes level {
        0%,
        100% {
          transform: translateY(11px);
        }
        50% {
          transform: translateY(-11px);
        }
      }

      /* Each swell travels exactly one wavelength, so the frame it ends on is
         the frame it began on and the roll never restarts. Linear: a wave
         arriving at a shore does not slow down halfway across the window. */
      .wave {
        animation: roll var(--t) linear infinite;
      }

      @keyframes roll {
        to {
          transform: translateX(-200px);
        }
      }

      /* Near water outruns far water. Four traversals against two is a ratio
         you read as distance rather than as speed, which is the only way a
         two-layer sea gets depth without a third dimension. */
      .wave.far {
        animation-duration: calc(var(--t) / 2);
      }

      .wave.near {
        animation-duration: calc(var(--t) / 4);
      }

      .body {
        stroke: none;
        fill: var(--clay-d);
      }

      .far .body {
        opacity: 0.11;
      }

      .near .body {
        opacity: 0.17;
      }

      /* Only the near swell gets a drawn surface. Two crest lines read as two
         seas; one reads as the top of the water, and the far layer becomes what
         it should be — a tint showing through from underneath. */
      .near .crest {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 1.25;
        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) {
        /* The swells stop; the tide keeps turning as depth rather than height.
           That preserves the subject — a level that changes — without moving a
           surface across the frame, and it leaves the card with a 28s loop
           running instead of a still photograph of the sea. */
        .wave {
          animation-name: none;
          transform: none;
        }

        .tide {
          animation-name: depth;
          animation-duration: calc(var(--t) * 2);
          animation-timing-function: ease;
          transform: none;
        }

        @keyframes depth {
          0%,
          100% {
            opacity: 0.58;
          }
          50% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <svg class="map" viewBox="0 0 400 210" preserveAspectRatio="none" aria-hidden="true">
        <line class="rule" x1="0" y1="88" x2="400" y2="88" />
        <line class="rule" x1="0" y1="110" x2="400" y2="110" />
        <line class="rule" x1="0" y1="132" x2="400" y2="132" />
        <g class="tide">
          <!-- Five wavelengths authored either side of the frame: the group
               travels 200 and is offset before it starts, so the path has to
               out-reach both together or the far edge runs dry mid-cycle. -->
          <!-- The far swell sits twenty units higher than the near one, so its
               crest clears the near water for most of the cycle. Distance in a
               flat drawing is height in the frame; give both layers the same
               baseline and the back one is only ever a tint. -->
          <g transform="translate(0 96)">
            <g class="wave far">
              <path
                class="body"
                d="M-200 0 q50 -18 100 0 q50 18 100 0 q50 -18 100 0 q50 18 100 0 q50 -18 100 0 q50 18 100 0 q50 -18 100 0 q50 18 100 0 q50 -18 100 0 q50 18 100 0 L800 220 L-200 220 Z"
              />
            </g>
          </g>
          <g transform="translate(-46 116)">
            <g class="wave near">
              <path
                class="body"
                d="M-200 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 L800 220 L-200 220 Z"
              />
              <path
                class="crest"
                d="M-200 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0 q50 -30 100 0 q50 30 100 0"
              />
            </g>
          </g>
        </g>
      </svg>
      <div class="copy">
        <p class="head">The level, turning</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.