Back to the library

Idle — light around the edge

Variant: Light around the edge

One point of light running the perimeter of a card, off a single conic gradient whose axis turns about the centre and is then masked down to a ring. Four separately animated edges is the obvious build and it cannot round a corner — the light either stops dead at each turn or needs four hand-timed segments that never quite meet — whereas spinning the axis makes the corners free. The angle is a registered `<angle>` custom property, since an unregistered one is a string and would jump 0 to 360 in a frame. Two decisions keep it honest. The oat border never leaves and the light rides on top of it, so there is no frame in which the card is missing part of its edge. And the arc is asymmetric — an 84° tail ramping up behind a 12° head that falls off in ten — because a symmetric one reads as a bar being dragged rather than something travelling. Worth knowing before you widen it: an angular sweep is constant in angle, not in arc length, so the head races along the short ends of a wide card and crawls down the long sides. Near-square is where this build looks right; a banner wants four sequenced edges instead.

ambientidleloopsweep

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>Idle — light around the edge</title>
    <style>
      /* The angle is the animated thing, not the element. Registering it is what
         makes it interpolable at all — an unregistered custom property is a
         string and jumps from 0deg to 360deg in one frame. */
      @property --_angle {
        syntax: '<angle>';
        inherits: false;
        initial-value: 0deg;
      }

      :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 — exactly one turn per cycle, so the last frame is the first
           frame and a loop this visible has no seam to hide. */
        --cycle: 6s;
        --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);
      }

      /* The oat border never leaves. The travelling light rides on top of it, so
         there is no frame in which the card is a card with a piece of its edge
         missing — which is what happens when the ring itself is the border. */
      .card {
        position: relative;
        display: flex;
        flex-direction: column;
        justify-content: center;
        gap: 9px;
        width: min(304px, calc(100% - 34px));
        height: clamp(126px, 36vw, 152px);
        padding: 0 clamp(18px, 5vw, 24px);
        border: 1px solid var(--oat);
        border-radius: calc(16px * var(--round));
        background: var(--paper);
      }

      /* One conic gradient whose axis turns about the card's centre, masked down
         to a ring. Four separately animated edges is the obvious build and it
         cannot round a corner: the light either stops dead at each turn or needs
         four hand-timed segments that never quite meet. Spinning the axis makes
         the corners free.

         The mask is the standard border trick — the layer is painted twice, once
         clipped to the content box and once to the whole box, and the two are
         differenced, leaving exactly the padding as a ring. */
      .ring {
        position: absolute;
        inset: -1px;
        padding: 2px;
        border-radius: calc(16px * var(--round));
        background: conic-gradient(
          from var(--_angle),
          transparent 0deg 252deg,
          color-mix(in srgb, var(--clay) 50%, transparent) 314deg,
          var(--clay-d) 336deg 348deg,
          transparent 358deg
        );
        -webkit-mask:
          linear-gradient(var(--slate) 0 0) content-box,
          linear-gradient(var(--slate) 0 0);
        -webkit-mask-composite: xor;
        mask:
          linear-gradient(var(--slate) 0 0) content-box,
          linear-gradient(var(--slate) 0 0);
        mask-composite: exclude;
        animation: chase var(--t) linear infinite;
      }

      /* An 84 degree tail ramping up behind a 12 degree head that falls off in
         ten. A symmetric arc reads as a bar sliding around the edge; the
         asymmetry is the only thing that says which way it is going, and
         direction is the difference between a light travelling and a light
         being dragged. */
      @keyframes chase {
        to {
          --_angle: 360deg;
        }
      }

      .mark {
        width: 24px;
        height: 24px;
        border-radius: calc(7px * var(--round));
        background: var(--clay);
      }

      .head {
        font-size: clamp(15px, 4.4vw, 18px);
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .sub {
        margin-top: 2px;
        font-size: clamp(11.5px, 3.3vw, 13px);
        color: color-mix(in srgb, var(--slate) 55%, transparent);
      }

      @media (prefers-reduced-motion: reduce) {
        /* The conic goes and a uniform ring takes its place, breathing at a
           third of the pace. The surface is still marked as the live one — it
           has just stopped pointing, which is the part that was motion. */
        .ring {
          background: color-mix(in srgb, var(--clay) 70%, transparent);
          animation-name: hold;
          animation-duration: calc(var(--t) * 2);
          animation-timing-function: ease;
        }

        @keyframes hold {
          0%,
          100% {
            opacity: 0.28;
          }
          50% {
            opacity: 0.9;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="card"
      role="img"
      aria-label="A card whose border carries a single point of light travelling continuously around its edge"
    >
      <div class="ring" aria-hidden="true"></div>
      <div class="mark"></div>
      <div>
        <p class="head">Nothing is happening</p>
        <p class="sub">Idle motion — the surface is live, not busy.</p>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.