> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/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.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="multiplayer-cursors preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/multiplayer-cursors.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/multiplayer-cursors.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add multiplayer-cursors" />

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>
```

## Source

<Accordion title={`multiplayer-cursors.html`}>
  ```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>
  ```
</Accordion>

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

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Simulated Cursor](/catalog/components/simulated-cursor.md)
- [Oversized Cursor](/catalog/components/oversized-cursor.md)
- [VFX Text Cursor](/catalog/blocks/vfx-text-cursor.md)
