> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Multiplayer Cursors

> Labeled collaborator cursors with token-derived colors and soft glows drift from spread positions into a shared center zone, proving live multiplayer presence.

# Multiplayer Cursors

Labeled collaborator cursors with token-derived colors and soft glows drift from spread positions into a shared center zone, proving live multiplayer presence.

**Multiplayer Cursors**

```html multiplayer-cursors.html theme={null}
<!doctype html>
<!--
  multiplayer-cursors, HyperFrames video primitive (pointers / loop / prove)

  Concept: labeled collaborator cursors, each with an SVG pointer, matching
  name chip, and soft color glow, reveal from spread positions and glide into
  a shared center zone. Their gentle authored drift makes live multiplayer
  presence visible without requiring a product-specific subject.

  Compiled-from evidence: power-reel source 15-frosted-cursors.html
  (production 60s reel, 2026-07). This extracts the deterministic labeled
  cursor mechanic and patches the multiplayer-arrows hole predicted by the
  pointers family definition. It does not carry over that reel's subject,
  background workspace, frosted chip, real pointer, or You cursor.

  Use when: proving that a canvas, document, board, or workspace supports live
  collaboration, shared presence, or multiple people editing together.

  Variables (declared in data-composition-variables below):
    - cursorCount (number, default 4, range 2 to 6): visible collaborators.
    - labels (string, default "Ana,Ken,Wen,Mia"): comma-separated names in
      cursor order. Missing or blank entries become deterministic Guest names.

  Envelope (fixed IN/OUT, elastic HOLD only, never gsap.timeScale()):
    IN_BASE  = 0.70s  layer settles in and cursors reveal with a fixed stagger
    HOLD     = elastic = max(0, D - (IN_BASE + OUT_BASE)); cursors converge,
               then drift between authored center-zone points for a finite
               number of sine.inOut legs that exactly fill the available time
    OUT_BASE = 0.50s  layer accelerates into a clean fade
    If D < IN_BASE + OUT_BASE, IN and OUT scale down together so IN + OUT == D
    and HOLD == 0.

  Sync point: none. This ambient collaboration loop has no causal action or
  fixed event that another primitive needs to synchronize with.

  Sound cue: none. The cursor motion is ambient and dispatches no hf:sfx event.

  Mount contract: this file is a mountable sub-composition. A host loads it via
  data-composition-src, and the runtime clones only <template> contents. The
  root carries no data-width/data-height. It fills the host slot with
  position:absolute, inset:0, and container-type:size, so every measurement can
  stay container-relative. The root is styled by #root, never by a class. The
  hardcoded timeline key remains "multiplayer-cursors" after mount flattening
  strips data-composition-id from the mounted root. Variables come only from
  window.__hyperframes.getVariables(), which owns merged defaults and host
  overrides inside a mounted composition.
-->
<html
  lang="en"
  data-composition-variables='[
    { "id": "cursorCount", "type": "number", "role": "content", "label": "Cursor count", "description": "Number of labeled collaborator cursors.", "default": 4, "min": 2, "max": 6, "step": 1 },
    { "id": "labels", "type": "string", "role": "content", "label": "Labels", "description": "Comma-separated cursor names, in order.", "default": "Ana,Ken,Wen,Mia" }
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <title>Multiplayer Cursors</title>
    <!-- Metadata only. Mounting discards everything outside <template>. -->
  </head>
  <body>
    <template>
      <div id="root" data-composition-id="multiplayer-cursors" data-duration="6" data-fps="30">
        <style>
          *,
          *::before,
          *::after {
            box-sizing: border-box;
          }

          /* Root: elastic and position-blind. The host owns its box. Styled by
             #root so composition scoping continues to match after mounting. */
          #root {
            position: absolute;
            inset: 0;
            container-type: size;
            isolation: isolate;
            overflow: hidden;
            color: var(--fg, #f8fafc);
            font-family: var(--font-body, Inter, system-ui, sans-serif);
          }

          .mc-clip,
          .mc-stage,
          .mc-layer {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
          }

          .mc-stage {
            transform-origin: center;
          }

          /* EDIT ZONE: the focus ring is only a neutral convergence landmark.
             Keep it secondary to the cursors and derived from theme tokens. */
          .mc-focus {
            position: absolute;
            left: 50%;
            top: 50%;
            width: 15cqw;
            aspect-ratio: 1;
            border: 0.18cqw solid color-mix(in srgb, var(--border, #334155) 72%, transparent);
            border-radius: var(--radius, 999cqw);
            background: radial-gradient(
              circle,
              color-mix(in srgb, var(--surface, #1e293b) 78%, transparent) 0 28%,
              color-mix(in srgb, var(--brand, #22c55e) 18%, transparent) 30% 48%,
              transparent 70%
            );
            box-shadow:
              0 0 8cqw color-mix(in srgb, var(--accent, #38bdf8) 16%, transparent),
              inset 0 0 2.4cqw color-mix(in srgb, var(--brand, #22c55e) 16%, transparent);
            transform: translate(-50%, -50%);
            opacity: 0.78;
          }

          .mc-focus::after {
            content: "";
            position: absolute;
            left: 50%;
            top: 50%;
            width: 1.35cqw;
            aspect-ratio: 1;
            border-radius: var(--radius, 999cqw);
            background: var(--brand, #22c55e);
            box-shadow: 0 0 2.4cqw color-mix(in srgb, var(--brand, #22c55e) 55%, transparent);
            transform: translate(-50%, -50%);
          }

          .mc-layer {
            z-index: 2;
          }

          /* INVARIANT: each cursor is a sized block. GSAP owns only x, y,
             rotation, scale, and opacity, so seeked spatial state is stable. */
          .mc-cursor {
            position: absolute;
            left: 0;
            top: 0;
            display: block;
            width: 20cqw;
            height: 10cqh;
            color: var(--mc-color, var(--brand, #22c55e));
            transform-origin: 0 0;
            will-change: transform, opacity;
          }

          .mc-cursor[data-tone="0"] {
            --mc-color: var(--brand, #22c55e);
          }

          .mc-cursor[data-tone="1"] {
            --mc-color: var(--accent, #38bdf8);
          }

          .mc-cursor[data-tone="2"] {
            --mc-color: color-mix(
              in oklch longer hue,
              var(--brand, #22c55e) 72%,
              var(--accent, #38bdf8)
            );
          }

          .mc-cursor[data-tone="3"] {
            --mc-color: color-mix(
              in oklch longer hue,
              var(--brand, #22c55e) 44%,
              var(--accent, #38bdf8)
            );
          }

          .mc-cursor[data-tone="4"] {
            --mc-color: color-mix(in oklch, var(--brand, #22c55e) 38%, var(--accent, #38bdf8));
          }

          .mc-cursor[data-tone="5"] {
            --mc-color: color-mix(in srgb, var(--accent, #38bdf8) 68%, var(--fg, #f8fafc));
          }

          .mc-glow {
            position: absolute;
            left: -6.5cqw;
            top: -6.5cqw;
            z-index: -1;
            width: 16cqw;
            aspect-ratio: 1;
            border-radius: var(--radius, 999cqw);
            background: radial-gradient(
              circle,
              color-mix(in srgb, var(--mc-color, var(--brand, #22c55e)) 44%, transparent),
              transparent 72%
            );
            filter: blur(1.4cqw);
            opacity: 0.58;
          }

          .mc-pointer {
            position: absolute;
            left: 0;
            top: 0;
            display: block;
            width: 4.8cqw;
            height: 4.8cqw;
            filter: drop-shadow(
              0 0.45cqw 0.7cqw color-mix(in srgb, var(--bg, #0b1120) 58%, transparent)
            );
          }

          .mc-pointer path {
            fill: currentColor;
            stroke: var(--fg, #f8fafc);
            stroke-width: 1.35;
            stroke-linejoin: round;
          }

          .mc-label {
            position: absolute;
            left: 3.45cqw;
            top: 3.55cqw;
            display: block;
            max-width: 15cqw;
            overflow: hidden;
            padding: var(--space-1, 0.7cqw) var(--space-2, 1.25cqw);
            border: 0.12cqw solid color-mix(in srgb, var(--fg, #f8fafc) 30%, transparent);
            border-radius: var(--radius, 1.2cqw);
            border-top-left-radius: 0.32cqw;
            background: color-mix(
              in srgb,
              var(--mc-color, var(--brand, #22c55e)) 84%,
              var(--surface, #1e293b)
            );
            color: var(--fg, #0b1120);
            font-family: var(--font-display, Inter, system-ui, sans-serif);
            font-size: 2.15cqw;
            font-weight: 720;
            line-height: 1;
            letter-spacing: 0.01em;
            text-overflow: ellipsis;
            white-space: nowrap;
            box-shadow:
              0 0.65cqw 1.5cqw color-mix(in srgb, var(--bg, #0b1120) 32%, transparent),
              inset 0 0.12cqw 0 color-mix(in srgb, var(--fg, #f8fafc) 22%, transparent);
          }
        </style>

        <div
          id="multiplayer-cursors-clip"
          class="mc-clip clip"
          data-start="0"
          data-duration="6"
          data-track-index="0"
        >
          <div class="mc-stage">
            <div class="mc-focus" aria-hidden="true"></div>
            <div class="mc-layer" aria-hidden="true"></div>
          </div>
        </div>

        <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
        <script>
          (function () {
            "use strict";

            var root = document.currentScript.closest("#root") || document.getElementById("root");
            // Literal by contract: mount flattening strips data-composition-id
            // from the mounted inner root before this registration is consumed.
            var compositionId = "multiplayer-cursors";
            var stage = root.querySelector(".mc-stage");
            var layer = root.querySelector(".mc-layer");

            // EDIT ZONE: variable defaults mirror the outer declaration.
            // getVariables owns the declared-defaults-plus-host-overrides merge.
            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            // INVARIANT: count is always an integer in the declared 2 to 6 range.
            var rawCount = Number(vars.cursorCount);
            var cursorCount = Math.max(
              2,
              Math.min(6, Number.isFinite(rawCount) ? Math.round(rawCount) : 4),
            );
            var rawLabels = vars.labels == null ? "Ana,Ken,Wen,Mia" : String(vars.labels);
            var parsedLabels = rawLabels.split(",").map(function (label) {
              return label.trim();
            });

            // Authored percentages are the single source for every path. Each
            // row is start, gather, drift, then the matching rotations.
            var WAYPOINTS = [
              { start: [10, 16], gather: [42, 40], drift: [46, 36], rotation: [-10, 2, 6] },
              { start: [66, 12], gather: [52, 38], drift: [58, 42], rotation: [8, -3, 4] },
              { start: [76, 38], gather: [57, 49], drift: [54, 55], rotation: [12, 3, -4] },
              { start: [64, 76], gather: [53, 59], drift: [48, 57], rotation: [-8, 4, -3] },
              { start: [20, 76], gather: [43, 60], drift: [38, 55], rotation: [9, -4, 5] },
              { start: [7, 46], gather: [38, 50], drift: [42, 45], rotation: [-12, 3, -5] },
            ];
            var POINTER_SVG =
              '<svg class="mc-pointer" viewBox="0 0 24 24" fill="none"><path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87c.48 0 .72-.58.38-.92L5.94 2.47a.5.5 0 0 0-.44.74Z" /></svg>';
            var cursors = [];

            for (var index = 0; index < cursorCount; index += 1) {
              var cursor = document.createElement("div");
              cursor.className = "mc-cursor";
              cursor.dataset.tone = String(index);
              cursor.innerHTML = '<span class="mc-glow"></span>' + POINTER_SVG;

              var label = document.createElement("span");
              label.className = "mc-label";
              label.textContent = parsedLabels[index] || "Guest " + (index + 1);
              cursor.appendChild(label);
              layer.appendChild(cursor);
              cursors.push({ element: cursor, path: WAYPOINTS[index] });
            }

            // RETIME RANGE: fixed envelope values and authored motion cadence.
            // HOLD alone expands. Finite drift legs are floored, then stretched
            // evenly to fill their remaining window without overshooting OUT.
            var IN_BASE = 0.7;
            var OUT_BASE = 0.5;
            var STAGE_IN_BASE = 0.42;
            var REVEAL_BASE = 0.26;
            var REVEAL_STAGGER_BASE = 0.055;
            var MOVE_STAGGER_BASE = 0.035;
            var CONVERGE_BASE = 1.35;
            var beat = parseFloat(getComputedStyle(root).getPropertyValue("--dur-beat")) || 0.55;
            var DRIFT_LEG_BASE = Math.max(0.55, beat * 1.45);

            var duration = Math.max(0.001, parseFloat(root.dataset.duration || "6"));
            var totalBase = IN_BASE + OUT_BASE;
            var envelopeScale = duration < totalBase ? duration / totalBase : 1;
            var IN = IN_BASE * envelopeScale;
            var OUT = OUT_BASE * envelopeScale;
            var STAGE_IN = STAGE_IN_BASE * envelopeScale;
            var REVEAL = REVEAL_BASE * envelopeScale;
            var REVEAL_STAGGER = REVEAL_STAGGER_BASE * envelopeScale;
            var MOVE_STAGGER = MOVE_STAGGER_BASE * envelopeScale;
            var HOLD = Math.max(0, duration - (IN + OUT));
            var HOLD_START = IN;
            var OUT_START = IN + HOLD;
            var CONVERGE = Math.min(CONVERGE_BASE * envelopeScale, HOLD);
            var DRIFT_START = HOLD_START + CONVERGE;
            var DRIFT_WINDOW = Math.max(0, HOLD - CONVERGE);

            gsap.set(stage, { opacity: 0, scale: 0.97 });
            cursors.forEach(function (cursor) {
              gsap.set(cursor.element, {
                x: cursor.path.start[0] + "cqw",
                y: cursor.path.start[1] + "cqh",
                rotation: cursor.path.rotation[0],
                opacity: 0,
                scale: 0.92,
              });
            });

            var tl = gsap.timeline({ paused: true });

            // IN: emphatic layer settle and deterministic per-cursor reveal.
            tl.to(stage, { opacity: 1, scale: 1, duration: STAGE_IN, ease: "power3.out" }, 0);
            cursors.forEach(function (cursor, index) {
              tl.to(
                cursor.element,
                { opacity: 1, scale: 1, duration: REVEAL, ease: "power2.out" },
                index * REVEAL_STAGGER,
              );
            });

            // HOLD: authored sine convergence, then a finite center-zone loop.
            if (CONVERGE > 0) {
              cursors.forEach(function (cursor, index) {
                var moveDelay = Math.min(index * MOVE_STAGGER, CONVERGE * 0.35);
                tl.to(
                  cursor.element,
                  {
                    x: cursor.path.gather[0] + "cqw",
                    y: cursor.path.gather[1] + "cqh",
                    rotation: cursor.path.rotation[1],
                    duration: Math.max(0.001, CONVERGE - moveDelay),
                    ease: "sine.inOut",
                  },
                  HOLD_START + moveDelay,
                );
              });
            }

            if (DRIFT_WINDOW >= DRIFT_LEG_BASE) {
              cursors.forEach(function (cursor, index) {
                var driftDelay = Math.min(index * MOVE_STAGGER, DRIFT_WINDOW * 0.12);
                var cursorWindow = Math.max(0, DRIFT_WINDOW - driftDelay);
                var driftLegCount = Math.max(1, Math.floor(cursorWindow / DRIFT_LEG_BASE));
                var driftLeg = cursorWindow / driftLegCount;
                var driftRepeat = Math.max(0, driftLegCount - 1);
                tl.fromTo(
                  cursor.element,
                  {
                    x: cursor.path.gather[0] + "cqw",
                    y: cursor.path.gather[1] + "cqh",
                    rotation: cursor.path.rotation[1],
                  },
                  {
                    x: cursor.path.drift[0] + "cqw",
                    y: cursor.path.drift[1] + "cqh",
                    rotation: cursor.path.rotation[2],
                    duration: driftLeg,
                    ease: "sine.inOut",
                    immediateRender: false,
                    yoyo: true,
                    repeat: driftRepeat,
                  },
                  DRIFT_START + driftDelay,
                );
              });
            }

            // OUT: asymmetric accelerated fade, while HOLD motion resolves.
            tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);

            tl.seek(0);

            window.__timelines = window.__timelines || {};
            window.__timelines[compositionId] = tl;
          })();
        </script>
      </div>
    </template>
  </body>
</html>
```

Copy npx hyperframes add multiplayer-cursors to the clipboard

That writes one file: `compositions/components/multiplayer-cursors.html`.

## Paste it into your composition

Open `compositions/components/multiplayer-cursors.html` and copy what is inside into your own composition.

A component has no size or duration of its own. It takes both from the composition
you paste it into.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable | Default | Accepts | What it does |
| - | - | - | - |
| `cursorCount` | `4` | 2 to 6, step 1 | Number of labeled collaborator cursors. |
| `labels` | `Ana,Ken,Wen,Mia` | string | Comma-separated cursor names, in order. |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap theme={null}
<div
  data-composition-id="multiplayer-cursors"
  data-composition-src="compositions/components/multiplayer-cursors.html"
  data-variable-values='{"cursorCount":4,"labels":"Ana,Ken,Wen,Mia"}'
></div>
```

Tagged `cursor` `pointer` `multiplayer` `collaboration` `presence` `loop` `prove`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)


## Related topics

- [Agent Cursor](/catalog/components/agent-cursor.md)
- [Annotate Cursor](/catalog/components/cursor-annotate.md)
- [Dart Cursor](/catalog/components/dart-cursor.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.