Back to the library

Badge — live indicator

Variant: Live indicator

The on-air tag, and the whole engineering problem is telling a live signal apart from a dot idly breathing. This one blips: a sharp ease-out flare to full, then a slow ease-in-out decay to a dim sustain it holds for most of the loop, because a broadcast pulse arrives fast and fades where an idle breath would be symmetric. Opacity carries the signal and the 12% of scale only gives the flare a body; neither reaches zero, since a dot that blinks fully out reads as the feed dropping rather than pulsing. Rust rather than the library’s clay, because on-air is a colour convention older than the web, and the label sits rock-still — the one thing moving is the dot, which is what keeps the tag legible the hundredth time it is seen.

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 — live indicator</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;
        --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 — 1.7s a beat. The flare is short and the rest is long, which is
           what separates a live signal blipping from a dot idly breathing. */
        --cycle: 1.7s;
        --t: calc(var(--cycle) / var(--speed));

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

      /* A pill — its radius is half its own height, so it stays literal and
         never takes --round; squaring an on-air tag would break its read. */
      .tag {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        padding: 7px 13px 7px 11px;
        border-radius: 999px;
        background: var(--paper);
        border: 1px solid var(--oat);
      }

      .dot {
        width: 8px;
        height: 8px;
        flex: none;
        border-radius: 50%;
        background: var(--rust); /* rust is the on-air colour, not clay */
        animation: onair var(--t) infinite;
      }

      .label {
        font-size: 12px;
        font-weight: 700;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--slate);
      }

      /* The onset is a sharp ease-out flare and the return is a slow
         ease-in-out settle: a live blip arrives fast and decays, where an idle
         breath would be symmetric. Opacity carries the signal; the 12% of scale
         only gives the flare a body. Neither reaches zero — a broadcast dot that
         blinks fully out reads as the feed dropping rather than pulsing. */
      @keyframes onair {
        0% {
          opacity: 0.5;
          transform: scale(0.9);
          animation-timing-function: var(--ease-out);
        }
        14% {
          opacity: 1;
          transform: scale(1.02);
          animation-timing-function: var(--ease-in-out);
        }
        58%,
        100% {
          opacity: 0.5;
          transform: scale(0.9);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The scale goes; the opacity pulse stays, because it is the thing that
           says the tag is live. Slowed to a calmer beat and the trough lifted so
           the pulse is a glow rather than a blink. */
        .dot {
          transform: none;
          animation-name: onair-dim;
          animation-duration: calc(var(--t) * 1.9);
          animation-timing-function: var(--ease-in-out);
        }

        @keyframes onair-dim {
          0%,
          100% {
            opacity: 0.62;
          }
          40% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <span
      class="tag"
      role="img"
      aria-label="A live broadcast tag whose dot pulses to show the feed is on air"
    >
      <span class="dot"></span>
      <span class="label">Live</span>
    </span>
  </body>
</html>

The variant family

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