Presence
Presence is playhtml’s “who’s here right now” layer. Every connected user has an identity, an optional cursor position, and any number of custom named channels you define. None of it persists. When the user disconnects, their presence clears.
Reach for presence when the lifetime you want is “while this person is on the page”. Reach for persistent data when you want state to survive a reload, and events for one-shot signals.
The unified API
Section titled “The unified API”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(); import { usePresence } from "@playhtml/react";
function StatusList() {
// presences is the live map (keyed by stable id); setMyPresence writes
// your own; myIdentity is your name/colors/publicKey.
const { presences, setMyPresence, myIdentity } = usePresence<{ text: string }>("status");
return (
<>
<button onClick={() => setMyPresence({ text: "focused" })}>focus</button>
{[...presences.values()].map((p) => p.status?.text)}
</>
);
}The hook subscribes and unsubscribes with the component lifecycle. Your channel value lives under the channel name (p.status), not flattened onto the view.
Cursor presence subscribes the same way
Section titled “Cursor presence subscribes the same way”Cursor movements are exposed as a special channel. Playhtml sends cursor motion through its realtime presence layer so cursor rendering can stay responsive without writing pointer movement into persistent shared data:
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.
Custom channels
Section titled “Custom channels”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 atselection:{ start, end }for collaborative text editingcursor-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); const { setMyPresence } = usePresence("status");
// Set: replace semantics per channel
setMyPresence({ text: "typing" });
// Clear: null
setMyPresence(null); There’s no partial/merge update for a channel. When you call setMyPresence, you overwrite that channel’s value for your user.
Isolated presence rooms
Section titled “Isolated presence rooms”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.
import { usePresenceRoom } from "@playhtml/react";
function Lobby() {
const room = usePresenceRoom("lobby-42");
if (!room) return null; // null until joined
room.presence.setMyPresence("status", { text: "ready" });
return <div>{room.presence.getPresences().size} in the lobby</div>;
}usePresenceRoom(name) joins the room and tears it down for you on unmount.
Try it live
Section titled “Try it live”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.
When to use data instead
Section titled “When to use data instead”Presence is for ambient awareness 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, cursors, and events, is on data essentials.