Skip to content

Presence

Presence is used for anything that is real-time and ephemeral (meaning it doesn’t stick around after you leave). For example, cursors only matter when you are actually on the page. When you leave, they are gone and there’s no record of them.

For the full API surface (PlayerIdentity, PresenceAPI, element users, cursor types), see the Presence & identity reference.

When you want things to stay after refreshing, use persistent data and use events for real-time events that don’t rely on updating data.

Element APIs call presence scoped to one element live user data. Configure your value with live, change it with setLive, and render everyone from users. It belongs to one element instead of the page-level playhtml.presence object.

You get one view of everyone connected, with both system fields (identity, cursor) and any custom channels you add. In vanilla JS that’s the playhtml.presence object; in React it’s the usePresence hook.

// Set (or clear) a custom channel
playhtml.presence.setMyPresence("status", { text: "focused", emoji: "🎯" });
playhtml.presence.setMyPresence("status", null);

// Read everyone (includes the local user, flagged with isMe)
const presences = playhtml.presence.getPresences();
for (const [id, p] of presences) {
  p.isMe;            // boolean
  p.playerIdentity;  // name, colors, publicKey
  p.cursor;          // { x, y, pointer } | null
  p.status;          // your custom channel (if set)
}

// Subscribe to a specific channel — fires only when that channel changes
const unsub = playhtml.presence.onPresenceChange("status", renderStatusRow);

// Your own identity
const me = playhtml.presence.getMyIdentity();

Cursor movements are included as a special example of this presence.

const unsub = playhtml.presence.onPresenceChange("cursor", (presences) => {
  renderCursorPositions(presences);
});

For pixel-accurate cursor rendering (including coordinate conversion across scrolled/zoomed pages), use the cursor system directly. See the Cursors page.

Channel names flatten into the top-level PresenceView. Pick names that don’t collide with the system fields (playerIdentity, cursor, isMe): collisions are silently dropped.

Common shapes:

  • status: { text, emoji } or a tag string for “focused / typing / afk”
  • focus: { elementId } to highlight which part of the page someone is looking at
  • selection: { start, end } for collaborative text editing
  • cursor-chat: a short message shown beside the user’s cursor

Setting vs clearing:

// Set: replace semantics per channel
playhtml.presence.setMyPresence("status", { text: "typing" });

// Clear: null
playhtml.presence.setMyPresence("status", null);

There’s no partial/merge update for a channel. When you call setMyPresence, you overwrite that channel’s value for your user.

Use page-level presence when the signal belongs to the room: online status, cursor position, lobby readiness, or a typing indicator that several parts of the page may read. Use element live values when the signal only matters for one playhtml element, like “who has joined this widget” or “which choice is this reader previewing here.”

Each item in users has two parts: user is the person’s identity (pid, name, color, and isMe), while live is the value they published for this element.

In vanilla HTML, add live to the initializer passed to register:

<button id="presence-count">0 readers here</button>

<script type="module">
  import { playhtml } from "playhtml";

  playhtml.register("presence-count", {
    live: { ready: false },
    onClick: (_event, { live, setLive }) => {
      setLive({ ready: !live.ready });
    },
    update: ({ element, users }) => {
      const ready = users.filter(({ live }) => live.ready).length;
      element.textContent = `${ready} of ${users.length} ready`;
    },
  });

  playhtml.init();
</script>

In React, the same fields are available from withSharedState and <CanPlayElement> render props:

<CanPlayElement id="presence-count" defaultData={{}} live={{ ready: false }}>
  {({ live, users, setLive }) => {
    const ready = users.filter((entry) => entry.live.ready).length;
    return (
      <button onClick={() => setLive({ ready: !live.ready })}>
        {ready} of {users.length} ready
      </button>
    );
  }}
</CanPlayElement>

Element live data has the same lifetime as presence: it clears when the user leaves, and a late visitor does not replay it.

Tabs in the same browser share one identity and appear as one user. To test with two people, open the second copy in an incognito window or another browser profile.

The main presence layer tracks everyone in the page’s room. When you want a presence channel scoped to something other than the page (a lobby, a document, a game table that several pages share), create a separate presence room.

const room = playhtml.createPresenceRoom("lobby-42");

room.presence.setMyPresence("status", { text: "ready" });
const unsub = room.presence.onPresenceChange("status", renderLobby);

// When you're done (e.g. the user leaves the lobby), tear it down:
room.destroy();

createPresenceRoom(name) returns a PresenceRoom ({ presence, destroy }), where presence is the same API as playhtml.presence, backed by its own connection. Always call destroy() when the room is no longer needed; it closes the connection and clears your presence for everyone else.

Each dot is one reader; the yellow-glowing dot is you. Pick a color and watch yours change for everyone else. Open this page in a second tab and you’ll see two dots. Close a tab and the dot disappears. This is presence: no persistence, no replay.

Looking for a “live reactions” bursting button? That’s an event, not presence. The docs for events have a live demo.

Presence is for the live sense of other people on the page. If you find yourself reaching for localStorage or a refresh-survivor, you want data, not presence.

Not sure which primitive fits? The full decision table, covering element data, page data, presence, element users, cursors, and events, is on data essentials.