Presence — going idle
Variant: Going idle
Presence decaying instead of presence blinking. The ring unwinds linearly across a 3.2s timeout — a timeout is a clock and a clock does not ease — and refills in 190ms on an ease-out, because coming back is a response to a person and every response in this library decelerates. That asymmetry is the piece. Nothing about the row changes while the clock runs: the avatar and the name hold full weight and flip on the instant the ring empties, since fading them gradually alongside it would claim a half-present state no presence system actually models. Reach for it on rosters where 'away' is inferred from silence rather than declared.
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>Presence — going idle</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)
);
--_muted: color-mix(in srgb, var(--slate) 45%, transparent);
/* Clock — 6.4s: a 3.2s timeout, a 1.7s lapse, and a 190ms return. The
two numbers that matter are the ratio between the drain and the
refill, and the fact that nothing else moves in between. */
--cycle: 6.4s;
--t: calc(var(--cycle) / 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);
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.ring {
position: relative;
flex: none;
width: 56px;
height: 56px;
}
.ring svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.track,
.clock {
fill: none;
stroke-width: 2.5;
/* Started at twelve o'clock, where a clock is read from. */
transform: rotate(-90deg);
transform-origin: 28px 28px;
}
.track {
stroke: var(--oat);
}
.clock {
stroke: var(--olive);
stroke-linecap: round;
stroke-dasharray: 150.8;
animation: lapse var(--t) infinite;
}
.face {
position: absolute;
inset: 6px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--olive) 18%, var(--paper));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 34%, transparent);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: color-mix(in srgb, var(--olive) 70%, var(--slate));
}
.meta {
display: grid;
gap: 3px;
}
.name {
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
}
/* Both labels share one grid cell, so the row is as wide as the longer of
them and neither reflows the name above when they trade places. */
.state {
display: grid;
font-size: 11.5px;
letter-spacing: -0.005em;
}
.state span {
grid-area: 1 / 1;
}
.live {
color: color-mix(in srgb, var(--olive) 82%, var(--slate));
animation: was-here var(--t) ease infinite;
}
.away {
color: var(--_muted);
opacity: 0;
animation: is-gone var(--t) ease infinite;
}
.face,
.name {
animation: dim var(--t) ease infinite;
}
/* The drain is linear and the refill is not. A timeout is a clock and a
clock does not ease; coming back is a response to a person and every
response in this library decelerates. 3.2s down against 190ms up — the
asymmetry is the entire idea, because presence is expensive to lose and
free to regain. */
@keyframes lapse {
0%,
6% {
stroke-dashoffset: 0;
animation-timing-function: linear;
}
56%,
82% {
stroke-dashoffset: 150.8;
animation-timing-function: var(--ease-out);
}
85%,
100% {
stroke-dashoffset: 0;
}
}
/* Nothing about the person changes while the clock is running. They are
active until the instant they are not — a row that fades gradually
alongside the ring would be claiming a half-present state that no
presence system actually models. */
@keyframes dim {
0%,
56% {
opacity: 1;
}
59.5%,
82% {
opacity: 0.45;
}
84%,
100% {
opacity: 1;
}
}
@keyframes was-here {
0%,
56% {
opacity: 1;
}
58.5%,
82% {
opacity: 0;
}
84%,
100% {
opacity: 1;
}
}
@keyframes is-gone {
0%,
56.5% {
opacity: 0;
}
59.5%,
82% {
opacity: 1;
}
83.5%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The unwinding ring stays: it is the datum, not decoration, and a
status row with the clock removed cannot say anything at all. Every
clock in the piece scales by the same 1.5, which is what keeps the
two labels trading places on the frame the ring empties. */
.clock,
.face,
.name,
.live,
.away {
animation-duration: calc(var(--t) * 1.5);
}
}
</style>
</head>
<body>
<div
class="row"
role="img"
aria-label="A roster row whose presence ring unwinds over an idle timeout, flips the status to Away, then refills the instant the person comes back"
>
<span class="ring">
<svg viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
<circle class="track" cx="28" cy="28" r="24" />
<circle class="clock" cx="28" cy="28" r="24" />
</svg>
<span class="face">MO</span>
</span>
<span class="meta">
<span class="name">Mara Ostrow</span>
<span class="state">
<span class="live">Active now</span>
<span class="away">Away</span>
</span>
</span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.