Back to the library

Sync — gone stale

Variant: Gone stale

Freshness is the one status that is never binary: presence flips and jobs finish, but a reading is exactly as old as it is. So the tile's ink fades linearly as the seconds-ago counter climbs — trust decaying at the clock's own rate — until an aged number looks aged before any label is read. The counter steps once a second because elapsed time is discrete when reported; the fade is continuous because doubt is not, and the two disagreeing in kind is the piece. The refresh then inverts everything at once: the new value rises through 3px of blur with 2px of lift — no travel belongs on a value that merely changed, but the lift gives it the direction the number moved — the ink snaps back to full on the file's one ease-out, and the clock starts over. For dashboards fed by polling, where how old the data is matters as much as the data.

statusdatacounterloop

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>Sync — gone stale</title>
    <style>
      @property --_age {
        syntax: '<integer>';
        inherits: true;
        initial-value: 0;
      }

      :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)
        );

        --_muted: color-mix(in srgb, var(--slate) 45%, transparent);

        /* Clock — 7.2s, and at the default speed the seconds-ago counter
           ticks on real seconds: the readout ages at the clock's own rate,
           which is the one claim a freshness indicator has to keep. */
        --cycle: 7.2s;
        --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);
      }

      .tile {
        width: clamp(168px, 40vw, 224px);
        padding: 14px 16px 13px;
        display: grid;
        gap: 2px;
        border-radius: calc(12px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
        animation: tile var(--t) linear infinite;
      }

      /* The reset hides in a dark window at the wrap. */
      @keyframes tile {
        0% {
          opacity: 0;
        }
        2.5% {
          opacity: 1;
        }
        95.5% {
          opacity: 1;
          animation-timing-function: ease;
        }
        100% {
          opacity: 0;
        }
      }

      .k {
        font-size: 11px;
        font-weight: 700;
        letter-spacing: 0.12em;
        text-transform: uppercase;
        color: var(--_muted);
      }

      .v {
        display: flex;
        align-items: baseline;
        gap: 5px;
      }

      /* Both readings share one grid cell; the swap travels nowhere. */
      .vals {
        display: grid;
        font-size: clamp(26px, 4.4vw, 32px);
        font-weight: 650;
        letter-spacing: -0.02em;
        font-variant-numeric: tabular-nums;
        line-height: 1.15;
        animation: ink var(--t) linear infinite;
      }

      .vals span {
        grid-area: 1 / 1;
      }

      /* The ink is the glance-read: it fades as the reading ages — trust
         decaying at the clock's rate, continuously, because staleness is the
         one status with no threshold to flip at — and snaps back to full the
         instant new data lands. Fresh is an event; ageing is not. */
      @keyframes ink {
        0% {
          opacity: 1;
        }
        57.5% {
          opacity: 0.44;
          animation-timing-function: var(--ease-out);
        }
        60.5% {
          opacity: 1;
          animation-timing-function: linear;
        }
        /* Same 0.135/s slope as the first decay — the rate is the claim, and
           the dark window at the wrap hides the snap back to full. */
        100% {
          opacity: 0.62;
        }
      }

      /* The update rises through 3px of blur: no travel belongs on a value
         that merely changed, but the 2px of lift gives it the direction the
         number moved. */
      .v-old {
        animation: v-old var(--t) ease infinite;
      }

      @keyframes v-old {
        0%,
        59.5% {
          opacity: 1;
          filter: blur(0);
          transform: translateY(0);
        }
        62.5%,
        100% {
          opacity: 0;
          filter: blur(3px);
          transform: translateY(-2px);
        }
      }

      .v-new {
        animation: v-new var(--t) ease infinite;
      }

      @keyframes v-new {
        0%,
        60% {
          opacity: 0;
          filter: blur(3px);
          transform: translateY(2px);
        }
        63%,
        100% {
          opacity: 1;
          filter: blur(0);
          transform: translateY(0);
        }
      }

      .unit {
        font-size: 12px;
        font-weight: 600;
        color: var(--_muted);
      }

      .f {
        margin-top: 6px;
        display: flex;
        align-items: center;
        gap: 5px;
        font-size: 11.5px;
        color: var(--_muted);
        font-variant-numeric: tabular-nums;
      }

      .refresh {
        width: 11px;
        height: 11px;
        flex: none;
        overflow: visible;
      }

      .refresh circle {
        fill: none;
        stroke: var(--clay);
        stroke-width: 1.8;
        stroke-linecap: round;
        stroke-dasharray: 22 6.3;
        transform: rotate(-90deg);
        transform-origin: 5.5px 5.5px;
        animation: spin var(--t) infinite;
      }

      /* One turn at the moment of refresh — a morph on screen, not an
         entrance, so it gets the in-out curve. */
      @keyframes spin {
        0%,
        58% {
          transform: rotate(-90deg);
          animation-timing-function: var(--ease-in-out);
        }
        64%,
        100% {
          transform: rotate(270deg);
        }
      }

      /* Elapsed time is discrete when reported: the counter steps once a
         second while the ink fades continuously. The step and the fade
         disagreeing in kind is the piece. */
      .age {
        animation: age var(--t) linear infinite;
      }

      .age b::before {
        counter-reset: a var(--_age);
        content: counter(a);
        font-weight: inherit;
      }

      @keyframes age {
        0%,
        13.8% {
          --_age: 0;
        }
        13.9%,
        27.7% {
          --_age: 1;
        }
        27.8%,
        41.6% {
          --_age: 2;
        }
        41.7%,
        55.5% {
          --_age: 3;
        }
        55.6%,
        60.9% {
          --_age: 4;
        }
        61%,
        74.9% {
          --_age: 0;
        }
        75%,
        88.8% {
          --_age: 1;
        }
        88.9%,
        100% {
          --_age: 2;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The decay and the counter stay — they are the reading — while the
           swap loses its blur and lift and the refresh glyph blinks once
           instead of turning. One shared 1.6x clock. */
        .tile,
        .vals,
        .v-old,
        .v-new,
        .refresh circle,
        .age {
          animation-duration: calc(var(--t) * 1.6);
        }

        .v-old {
          animation-name: v-old-rm;
          filter: none;
          transform: none;
        }

        @keyframes v-old-rm {
          0%,
          59.5% {
            opacity: 1;
          }
          62.5%,
          100% {
            opacity: 0;
          }
        }

        .v-new {
          animation-name: v-new-rm;
          filter: none;
          transform: none;
        }

        @keyframes v-new-rm {
          0%,
          60% {
            opacity: 0;
          }
          63%,
          100% {
            opacity: 1;
          }
        }

        .refresh circle {
          animation-name: spin-rm;
          transform: rotate(-90deg);
        }

        @keyframes spin-rm {
          0%,
          58% {
            opacity: 1;
          }
          59.5%,
          61% {
            opacity: 0.35;
          }
          63%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="tile"
      role="img"
      aria-label="A dashboard tile whose reading fades as its seconds-ago counter climbs, then snaps back to full ink when a refresh lands a new value"
    >
      <span class="k">Throughput</span>
      <span class="v">
        <span class="vals">
          <span class="v-old">482</span>
          <span class="v-new">517</span>
        </span>
        <span class="unit">req/s</span>
      </span>
      <span class="f">
        <svg class="refresh" viewBox="0 0 11 11" aria-hidden="true">
          <circle cx="5.5" cy="5.5" r="4.5" />
        </svg>
        <span class="age"><b></b>s ago</span>
      </span>
    </div>
  </body>
</html>

The variant family

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