Back to the library

Badge — connection strength

Variant: Connection strength

A four-bar meter climbing to full, dropping to a single bar, and recovering — a live reading of link quality rather than a fixed icon. Only opacity animates: the bar heights are fixed and a lit rung is full clay against the same clay held faint, so counting brightness is counting strength and nothing touches the layout pass. Four identical timelines staggered 150ms apart are what turn a set of blinking bars into a climb up and a recede back down, and the first bar’s delay is zero, so it stays lit through the trough and the meter never reads as fully disconnected. The trough is also the first frame, which is what lets a loop this repetitive close with no seam to catch.

statusbadgeloop

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>Badge — connection strength</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 — 3.4s: strength climbs bar by bar, holds full, drops back to a
           single bar, holds weak, and the trough is the first frame so the loop
           has no seam. --_step is the delay between rungs; it rides --speed, so a
           pace roll keeps the climb evenly rungged instead of bunching. */
        --cycle: 3.4s;
        --t: calc(var(--cycle) / var(--speed));
        --_step: calc(150ms / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 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);
      }

      .signal {
        display: flex;
        align-items: flex-end;
        gap: 4px;
        height: 26px;
      }

      /* Heights are fixed and never animated — only opacity moves, so the meter
         stays off the layout pass. A lit bar is full clay, an unlit one the same
         clay held faint, which is why counting brightness reads as strength. */
      .bar {
        width: 6px;
        border-radius: calc(2px * var(--round));
        background: var(--clay);
        opacity: 0.16;
        animation: rung var(--t) var(--ease-out) infinite;
      }

      .bar:nth-child(1) {
        height: 8px;
      }
      .bar:nth-child(2) {
        height: 14px;
        animation-delay: var(--_step);
      }
      .bar:nth-child(3) {
        height: 20px;
        animation-delay: calc(var(--_step) * 2);
      }
      .bar:nth-child(4) {
        height: 26px;
        animation-delay: calc(var(--_step) * 3);
      }

      /* Each rung lights, holds through the plateau, then falls — the stagger
         delays turn four identical timelines into a climb up and a recede down.
         The first bar's delay is zero, so it is lit at the trough and the meter
         never reads as fully disconnected. */
      @keyframes rung {
        0%,
        6% {
          opacity: 0.16;
        }
        20%,
        56% {
          opacity: 1;
        }
        72%,
        100% {
          opacity: 0.16;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Opacity is the whole animation, so nothing has to be removed — only
           calmed: the cycle stretches and the trough lifts so the rungs fade
           between strong and weak rather than snapping dark. */
        .bar {
          animation-name: rung-calm;
          animation-duration: calc(var(--t) * 1.7);
        }
        .bar:nth-child(2) {
          animation-delay: calc(var(--_step) * 1.7);
        }
        .bar:nth-child(3) {
          animation-delay: calc(var(--_step) * 3.4);
        }
        .bar:nth-child(4) {
          animation-delay: calc(var(--_step) * 5.1);
        }

        @keyframes rung-calm {
          0%,
          6% {
            opacity: 0.34;
          }
          20%,
          56% {
            opacity: 1;
          }
          72%,
          100% {
            opacity: 0.34;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="signal"
      role="img"
      aria-label="A four-bar connection meter climbing to full strength, dropping to one bar, and recovering"
    >
      <span class="bar"></span>
      <span class="bar"></span>
      <span class="bar"></span>
      <span class="bar"></span>
    </div>
  </body>
</html>

The variant family

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