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.
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.