Presence — someone joins
Variant: Someone joins
The multiplayer roll call: a seat opens, a sixth person lands in it, and later someone else drops out and the row closes up. Order is the whole argument — the overflow chip steps aside 120ms before the joiner starts arriving, and the survivors close the gap only once the leaver has finished fading, because a row that moves at the same time as the person moving through it reads as a collision rather than a departure. The headcount is a registered integer stepped with steps(1, end), so it changes on the frame the stack settles instead of the frame the motion starts: the number is the outcome, not the intention.
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 — someone joins</title>
<style>
/* The headcount is a registered integer so it can be stepped on an exact
keyframe rather than swapped by a script. It never interpolates: every
segment below is steps(1, end), so the number holds and then snaps. */
@property --_n {
syntax: '<integer>';
initial-value: 5;
inherits: false;
}
: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) 52%, transparent);
/* Clock — 6s stages join, hold, leave, hold. One slot of advance is
24px everywhere: 32px of avatar less 8px of overlap, so a chip that
steps aside and a row that closes up move exactly one person. The
overlap stops at a quarter because past that the second initial of
every face but the last one disappears under its neighbour. */
--cycle: 6s;
--t: calc(var(--cycle) / var(--speed));
--_advance: 24px;
--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);
}
.presence {
display: flex;
align-items: center;
gap: 12px;
/* The loop ends holding a different set of people than it started with,
so the reset is a crossfade rather than a visible un-join. */
animation: session var(--t) linear infinite;
}
.stack {
display: flex;
align-items: center;
}
/* Two elements per person: the slot owns horizontal travel, the face owns
scale and opacity. Split, each gets its own curve without a composite
transform string that both keyframe lists would have to agree on. */
.slot {
flex: none;
margin-left: -8px;
}
.slot:first-child {
margin-left: 0;
}
.face {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--ivory);
background: var(--paper);
box-shadow: inset 0 0 0 1px var(--oat);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--_muted);
}
.s2 .face {
background: color-mix(in srgb, var(--clay) 16%, var(--paper));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--clay) 34%, transparent);
color: var(--clay-d);
}
/* The joiner: absent at rest, so it starts scaled back and transparent. */
.s4 .face {
background: color-mix(in srgb, var(--olive) 20%, var(--paper));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 40%, transparent);
color: color-mix(in srgb, var(--olive) 70%, var(--slate));
opacity: 0;
transform: scale(0.92);
animation: join var(--t) infinite;
}
.overflow .face {
background: var(--oat);
box-shadow: none;
letter-spacing: 0;
}
.count {
font-size: 13px;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
color: var(--_muted);
counter-reset: n var(--_n);
animation: headcount var(--t) infinite;
}
.count::before {
content: counter(n) ' online';
}
.s2 .face {
animation: leave var(--t) infinite;
}
.s3,
.s4 {
animation: close-gap var(--t) infinite;
}
/* Layout reserves the joiner's slot whether or not anyone is in it, so
while the seat is empty the overflow chip has to be pulled one advance
left of where flex parked it. */
.overflow {
transform: translateX(calc(-1 * var(--_advance)));
animation: make-room var(--t) infinite;
}
/* The room is made before the person arrives. The chip steps aside at 20%
and the face only starts landing at 22% — 120ms of lead, which is the
difference between a stack opening up and a stack being barged into. */
@keyframes make-room {
0%,
20% {
transform: translateX(calc(-1 * var(--_advance)));
animation-timing-function: var(--ease-out);
}
24%,
52.5% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
56.5%,
100% {
transform: translateX(calc(-1 * var(--_advance)));
}
}
@keyframes join {
0%,
22% {
opacity: 0;
transform: scale(0.92);
animation-timing-function: var(--ease-out);
}
25.5%,
100% {
opacity: 1;
transform: scale(1);
}
}
/* Leaving costs 150ms against the joiner's 210ms, and it scales to 0.9
rather than to nothing — a person dropping off a call is a thing that
was there, not a thing that never was. */
@keyframes leave {
0%,
50% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
52.5%,
100% {
opacity: 0;
transform: scale(0.9);
}
}
/* The survivors close up only once the gap is actually empty. Start them
together and the row appears to walk through the person leaving. */
@keyframes close-gap {
0%,
52.5% {
transform: translateX(0);
animation-timing-function: var(--ease-out);
}
56.5%,
100% {
transform: translateX(calc(-1 * var(--_advance)));
}
}
/* The count changes on the frame the stack settles, never on the frame the
motion starts: the number is the outcome, not the intention. */
@keyframes headcount {
0% {
--_n: 5;
animation-timing-function: steps(1, end);
}
25.5% {
--_n: 6;
animation-timing-function: steps(1, end);
}
56.5% {
--_n: 5;
animation-timing-function: steps(1, end);
}
100% {
--_n: 5;
}
}
@keyframes session {
0%,
1% {
opacity: 0;
}
5%,
92% {
opacity: 1;
}
97%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* No travel and no reset crossfade — but arrival and departure are the
content, so both keep their opacity, and the headcount keeps stepping
with them. All four clocks scale by the same 1.6, which is what keeps
the number landing on the same beat as the face it counts. */
.presence,
.s3,
.s4,
.overflow {
animation: none;
opacity: 1;
transform: none;
}
.s4 .face,
.s2 .face {
transform: none;
animation-duration: calc(var(--t) * 1.6);
animation-name: join-fade;
}
.s2 .face {
animation-name: leave-fade;
}
.count {
animation-duration: calc(var(--t) * 1.6);
}
@keyframes join-fade {
0%,
22% {
opacity: 0;
}
25.5%,
100% {
opacity: 1;
}
}
@keyframes leave-fade {
0%,
50% {
opacity: 1;
}
52.5%,
100% {
opacity: 0;
}
}
}
</style>
</head>
<body>
<div
class="presence"
role="img"
aria-label="An avatar stack: the row opens a slot and a sixth person lands in it, then someone else drops out and the row closes up"
>
<div class="stack">
<span class="slot s1"><span class="face">JD</span></span>
<span class="slot s2"><span class="face">RK</span></span>
<span class="slot s3"><span class="face">TW</span></span>
<span class="slot s4"><span class="face">AL</span></span>
<span class="slot overflow"><span class="face">+2</span></span>
</div>
<span class="count"></span>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.