Back to the library

Ornament — stepped bloom

Variant: Stepped bloom

A ripple in masonry: stepped-diamond rings born at a summit a quarter-cycle apart, spreading to the rim and thinning out as they go. One decelerating curve covers each ring’s whole life, because a wave sheds energy as it travels — quick out of the centre, slow at the edge — and the birth is at opacity zero so a ring grows out of the summit rather than appearing on it. The stepped outline is what keeps this carving instead of water; a circle doing exactly this would be a pond, and the library already has a pond. Stroke weight is pinned by non-scaling-stroke from birth to rim — let it scale and young rings read bold, old rings faint, a depth cue the piece is not claiming. Negative delays mean the field is populated on the first frame: ambient motion must never appear to have started. Where the beacon says “look here now”, this says nothing at all — it is emission as decoration, for surfaces that should feel alive rather than watched.

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>Ornament — stepped bloom</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 ring's whole life, birth to rim. Four rings ride it a
           quarter-cycle apart on negative delays, so the field is already
           populated on the first frame: ambient motion must not appear to
           have started. */
        --cycle: 7.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-accent: cubic-bezier(0.32, 0.72, 0, 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);
      }

      .field {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        /* Faded across the bottom third rather than stopped above the copy, so
           the rings read 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%);
      }

      /* The line weight never grows with the ring: non-scaling-stroke pins it
         at 1.4px from birth to rim. Let the stroke scale and the young rings
         read as bold and the old ones as faint — a depth cue this piece is
         not claiming. */
      .r {
        fill: none;
        stroke: var(--clay-d);
        stroke-width: 1.4;
        vector-effect: non-scaling-stroke;
        opacity: 0;
        transform-box: fill-box;
        transform-origin: center;
        animation: bloom var(--t) var(--ease-accent) infinite;
        animation-delay: calc(var(--_k) * var(--t) / -4);
      }

      /* One decelerating segment for the whole travel: a wave sheds energy as
         it spreads, so the ring is quick out of the summit and slow at the
         rim. The stepped edge is what keeps this carving rather than water —
         a circle doing exactly this would be a pond. */
      @keyframes bloom {
        0% {
          transform: scale(0.22);
          opacity: 0;
        }
        14% {
          opacity: 0.46;
        }
        82% {
          opacity: 0.16;
        }
        100% {
          transform: scale(1.16);
          opacity: 0;
        }
      }

      .summit {
        fill: var(--clay);
        opacity: 0.55;
      }

      .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) {
        /* Growth is travel, so it goes: the four rings park at the sizes the
           ripple would have passed through, and what survives is brightness
           walking outward through them on the doubled cycle. The nesting is
           the piece's actual subject, and it is better legible standing
           still. */
        .r {
          animation: glow calc(var(--t) * 2) ease infinite;
          animation-delay: calc(var(--_k) * var(--t) / 2 - var(--t) * 2);
          transform: scale(calc(0.28 + var(--_k) * 0.28));
        }

        @keyframes glow {
          0%,
          100% {
            opacity: 0.16;
          }
          25% {
            opacity: 0.44;
          }
          50% {
            opacity: 0.16;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <svg
        class="field"
        viewBox="0 0 400 210"
        preserveAspectRatio="xMidYMid slice"
        aria-hidden="true"
      >
        <defs>
          <!-- A stepped diamond: three treads per quadrant, long flats top and
               bottom. The union-of-squares outline is what makes the growth
               read as masonry rather than ripples in water. -->
          <path
            id="sd"
            d="M-64,-8 V8 H-48 V24 H-32 V40 H-16 V56 H16 V40 H32 V24 H48 V8 H64 V-8 H48 V-24 H32 V-40 H16 V-56 H-16 V-40 H-32 V-24 H-48 V-8 Z"
          />
        </defs>
        <g transform="translate(200 92)">
          <use class="r" style="--_k: 0" href="#sd" />
          <use class="r" style="--_k: 1" href="#sd" />
          <use class="r" style="--_k: 2" href="#sd" />
          <use class="r" style="--_k: 3" href="#sd" />
          <use class="summit" href="#sd" transform="scale(0.1)" />
        </g>
      </svg>
      <div class="copy">
        <p class="head">Ripples in stone</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.