Back to the library

Ornament — fret crawl

Variant: Fret crawl

Two carved friezes marching between fixed register rules, the lower one mirrored and running counter at two thirds the pace. Each band advances exactly its own 26px pitch per cycle, so the last frame is pixel-identical to the first and the loop has no seam to catch — the same arithmetic that makes the hatched drift seamless, applied to a drawn ornament instead of a texture. Linear, because a procession does not ease: any curve here reads as the band hesitating, and a texture caught hesitating is a texture caught animating. The counter band rides inside a mirrored group, so one set of keyframes drives both directions and the glyphs face the way they march. The still hairlines are load-bearing — bands sliding between fixed rules read as courses in a wall; without them it is two stacked marquees. For dividers, headers, and edges that deserve a border rather than a rule.

ambientloopsvgsweep

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>Ornament — fret crawl</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 pattern period of travel. The band advances exactly its
           own 26px pitch per cycle, so the last frame is pixel-identical to
           the first and a loop this slow has no seam to catch. The counter
           band runs the same distance over 7.8s: 2:3 with the 5.2s cycle, so
           the pair re-aligns every three cycles instead of every one. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));
        --_t-counter: calc(7.8s / 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);
      }

      .field {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        /* Faded across the bottom third rather than stopped above the copy, so
           the friezes read as bands carved into the surface the text sits on. */
        -webkit-mask-image: linear-gradient(var(--slate) 46%, transparent 92%);
        mask-image: linear-gradient(var(--slate) 46%, transparent 92%);
      }

      .glyphs {
        fill: none;
        stroke-width: 1.5;
      }

      /* Linear, both bands: a procession does not ease. Any curve here would
         read as the band hesitating, and a texture that hesitates is a texture
         you have caught animating. */
      .march {
        stroke: var(--clay-d);
        opacity: 0.5;
        animation: crawl var(--t) linear infinite;
      }

      /* The counter band rides inside a mirrored group, so the same leftward
         keyframes come out of it moving right — one set of keyframes, two
         directions, and the glyphs face the way they march. */
      .counter {
        stroke: var(--slate);
        opacity: 0.2;
        animation: crawl var(--_t-counter) linear infinite;
      }

      @keyframes crawl {
        to {
          transform: translateX(-26px);
        }
      }

      /* The register rules hold still. Bands sliding between fixed hairlines
         read as courses in a wall; without the rules they read as two
         marquees that happen to be stacked. The lone rule below the second
         band sinks into the mask — the next course, not yet carved. */
      .rule {
        stroke: var(--slate);
        stroke-width: 1;
        opacity: 0.16;
      }

      .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 march stops; the two courses trade brightness instead, in
           opposite phase so the panel always has one band carrying it. The
           cycle doubles — the trade is a glow, not a blink. */
        .march {
          animation: trade-lead calc(var(--t) * 2) ease infinite;
          transform: none;
        }

        .counter {
          animation: trade-follow calc(var(--t) * 2) ease infinite;
          transform: none;
        }

        @keyframes trade-lead {
          0%,
          100% {
            opacity: 0.5;
          }
          50% {
            opacity: 0.26;
          }
        }

        @keyframes trade-follow {
          0%,
          100% {
            opacity: 0.2;
          }
          50% {
            opacity: 0.38;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <svg
        class="field"
        viewBox="0 0 400 210"
        preserveAspectRatio="xMidYMid slice"
        aria-hidden="true"
      >
        <defs>
          <!-- One period is 26px: a 20px hook and a 6px rest. The strip runs a
               full period past both edges, so neither end is ever on stage. -->
          <path
            id="fret"
            class="glyphs"
            d="M-26,14 V0 H-6 V14 H-18 V6 H-12 M0,14 V0 H20 V14 H8 V6 H14 M26,14 V0 H46 V14 H34 V6 H40 M52,14 V0 H72 V14 H60 V6 H66 M78,14 V0 H98 V14 H86 V6 H92 M104,14 V0 H124 V14 H112 V6 H118 M130,14 V0 H150 V14 H138 V6 H144 M156,14 V0 H176 V14 H164 V6 H170 M182,14 V0 H202 V14 H190 V6 H196 M208,14 V0 H228 V14 H216 V6 H222 M234,14 V0 H254 V14 H242 V6 H248 M260,14 V0 H280 V14 H268 V6 H274 M286,14 V0 H306 V14 H294 V6 H300 M312,14 V0 H332 V14 H320 V6 H326 M338,14 V0 H358 V14 H346 V6 H352 M364,14 V0 H384 V14 H372 V6 H378 M390,14 V0 H410 V14 H398 V6 H404 M416,14 V0 H436 V14 H424 V6 H430"
          />
        </defs>
        <line class="rule" x1="0" y1="38.5" x2="400" y2="38.5" />
        <line class="rule" x1="0" y1="67.5" x2="400" y2="67.5" />
        <line class="rule" x1="0" y1="88.5" x2="400" y2="88.5" />
        <line class="rule" x1="0" y1="117.5" x2="400" y2="117.5" />
        <line class="rule" x1="0" y1="146.5" x2="400" y2="146.5" />
        <g transform="translate(0 46)">
          <use class="march" href="#fret" />
        </g>
        <g transform="translate(410 96) scale(-1 1)">
          <use class="counter" href="#fret" />
        </g>
      </svg>
      <div class="copy">
        <p class="head">A carved procession</p>
        <p class="sub">Ambient ornament. 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.