Back to the library

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.

presencestatuslist

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.