Back to the library

Presence — a live cursor

Variant: Live cursor

A collaborator's pointer crossing a document in four legs. The name label is not welded to it — it is a sibling running the identical keyframes 90ms late, which is the entire trick and the only reason the cursor feels pushed by a hand somewhere else rather than driven along a track. Every leg decelerates into its target, since a remote cursor is a client chasing a position it was told about a moment ago. The label leans on vertical travel only: rotation cannot express horizontal lag and the 90ms already has. It dims over 312ms of stillness and returns in 52ms on the first frame of the next leg, because a name is noise on a parked cursor and the only thing that matters on a moving one.

presencestatusgestureloop

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 — a live cursor</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;
        --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)
        );

        /* Clock — 5.2s of four legs and three long stops. The stops are most of
           it on purpose: a remote pointer is a person reading, not a thing on a
           track, and the pauses are what make the moves read as intention. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));
        /* The label runs the identical keyframes 90ms late. That single number
           is the whole trick, and it divides by --speed so the lag stays 90ms
           of felt time under any pace roll. */
        --_lag: calc(90ms / var(--speed));

        /* Travel is a fraction of the surface, never a pixel count, so the whole
           choreography survives the card being 272px or 240px wide. */
        --_w: min(272px, 86vw);

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

      .canvas {
        position: relative;
        width: var(--_w);
        height: 152px;
        padding: 16px 18px;
        border-radius: calc(12px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
        overflow: hidden;
      }

      .doc {
        display: grid;
        gap: 9px;
      }

      .doc span {
        height: 7px;
        border-radius: 3.5px;
        background: color-mix(in srgb, var(--oat) 70%, transparent);
      }

      .doc span:nth-child(1) {
        width: 62%;
      }
      .doc span:nth-child(2) {
        width: 86%;
      }
      .doc span:nth-child(3) {
        width: 74%;
      }
      .doc span:nth-child(4) {
        width: 44%;
      }

      /* Pointer and label are siblings on identical keyframes rather than parent
         and child, which is the only reason the label is free to run late. */
      .ghost,
      .flag {
        position: absolute;
        left: 0;
        top: 0;
        transform: translate(calc(var(--_w) * 0.08), 16px);
      }

      .ghost {
        width: 14px;
        height: 18px;
        animation: travel var(--t) infinite;
      }

      .arrow {
        display: block;
        width: 100%;
        height: 100%;
        fill: var(--clay-d);
        stroke: var(--ivory);
        stroke-width: 1.4;
        stroke-linejoin: round;
      }

      .flag {
        animation:
          travel var(--t) infinite,
          attention var(--t) ease infinite;
        animation-delay: var(--_lag);
      }

      .pill {
        display: block;
        translate: 13px 17px;
        padding: 3px 7px;
        border-radius: calc(5px * var(--round));
        background: var(--clay-d);
        color: var(--ivory);
        font-size: 11px;
        font-weight: 600;
        letter-spacing: -0.01em;
        white-space: nowrap;
        /* Pinned at the end nearest the pointer: the label swings off the
           cursor the way a pennant swings off its pole, not around its middle. */
        transform-origin: 2px 50%;
        animation: sway var(--t) var(--ease-out) infinite;
        animation-delay: var(--_lag);
      }

      /* Every leg decelerates into its target. A remote cursor is a client
         chasing a position it was told about a moment ago, so it always arrives
         easing off — a linear leg would claim the sender's hand moved at a
         constant rate, which no hand does. */
      @keyframes travel {
        0%,
        6% {
          transform: translate(calc(var(--_w) * 0.08), 16px);
          animation-timing-function: var(--ease-out);
        }
        18%,
        35% {
          transform: translate(calc(var(--_w) * 0.6), 40px);
          animation-timing-function: var(--ease-out);
        }
        45%,
        66% {
          transform: translate(calc(var(--_w) * 0.26), 92px);
          animation-timing-function: var(--ease-out);
        }
        74%,
        87% {
          transform: translate(calc(var(--_w) * 0.7), 100px);
          animation-timing-function: var(--ease-out);
        }
        100% {
          transform: translate(calc(var(--_w) * 0.08), 16px);
        }
      }

      /* The lean tracks vertical travel only — horizontal lag is already carried
         by the 90ms delay, and rotation cannot express it. Down the page the far
         end of the label rides up; the long climb home swings it the other way. */
      @keyframes sway {
        0%,
        7% {
          rotate: 0deg;
        }
        12% {
          rotate: -3deg;
        }
        20%,
        36% {
          rotate: 0deg;
        }
        40% {
          rotate: -5deg;
        }
        50%,
        67% {
          rotate: 0deg;
        }
        70% {
          rotate: -1.5deg;
        }
        78%,
        88% {
          rotate: 0deg;
        }
        93% {
          rotate: 6deg;
        }
        100% {
          rotate: 0deg;
        }
      }

      /* Names are noise on a still cursor and the only thing that matters on a
         moving one, so the label dims over 312ms of stillness and comes back in
         52ms — on the first frame of the next leg, not after it. */
      @keyframes attention {
        0%,
        24% {
          opacity: 1;
        }
        30%,
        34% {
          opacity: 0.35;
        }
        35%,
        51% {
          opacity: 1;
        }
        57%,
        65% {
          opacity: 0.35;
        }
        66%,
        80% {
          opacity: 1;
        }
        85%,
        86% {
          opacity: 0.35;
        }
        87%,
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Parked mid-surface with no travel and no lean. What survives is the
           part that is information rather than physics: the name still goes
           quiet and comes back, so the row still says someone is here. */
        .ghost,
        .flag {
          transform: translate(calc(var(--_w) * 0.5), 60px);
        }

        .ghost {
          animation: none;
        }

        .flag {
          animation: attention calc(var(--t) * 1.8) ease infinite;
          animation-delay: 0s;
        }

        .pill {
          animation: none;
          rotate: 0deg;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="canvas"
      role="img"
      aria-label="A collaborator's cursor crossing a document in four legs, its name label trailing a beat behind and dimming whenever the pointer holds still"
    >
      <div class="doc">
        <span></span>
        <span></span>
        <span></span>
        <span></span>
      </div>

      <span class="ghost">
        <svg class="arrow" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg">
          <path d="M1 1 L1 14.8 L4.5 11.6 L6.7 16.6 L9.3 15.4 L7.1 10.5 L11.8 10.3 Z" />
        </svg>
      </span>

      <span class="flag"><span class="pill">Mara</span></span>
    </div>
  </body>
</html>

The variant family

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