People and presence
People refers to who’s here right now, and what they’re doing. Each person has presence: small bits of live state that can be seen by everyone, can only be changed by each individual, and goes away when they leave. Use people for “3 people here” counts, rosters, typing indicators, cursors, and showing who’s using an element right now.
playhtml.people is everyone on the page right now. Each person looks like this:
{
pid: "a1b2…", // a stable id for this person
name: "Ana", // their name (may be undefined)
color: "#3b82f6", // their color
isMe: false, // true for you
presence: { status: "typing" }, // what they're doing right now
}
Presence lives in three places, and they all use the same names (everyone, me, setMyPresence):
- On the page:
playhtml.people. One presence object per person, shared by everything on the page. Cursors live here too, atperson.presence.cursor. - On an element: inside an element,
everyoneis the people using it, each with their presence on that element. - In a room:
playhtml.createPresenceRoom(name).people, for presence shared across pages.
When you want something to stay after a refresh, use saved data. For one-off moments that don’t change any state (a burst of confetti), use events.
A few things to know:
- A tab left in the background counts as leaving after a couple of minutes. Its presence comes back as soon as the tab is visible again.
- A cursor that stops moving fades out after about 30 seconds.
- Tabs in the same browser share one identity and show up as one person. To test with two people, open the second copy in an incognito window or another browser profile.
Names and colors
Section titled “Names and colors”Each person’s pid, name, and color are saved, and they work whether or not cursors are on.
pidis a stable id for this person in this browser. Use it as a Reactkeyor to match someone across features.nameis whatever they’ve set, orundefined.coloris always set. playhtml picks one the first time someone visits.
people.me is you. Setting me.name or me.color saves the change and shows it to everyone (and updates your cursor, if cursors are on).
Names are trimmed and cut to 32 characters, and a blank name clears it. me.color takes any CSS color ("#3b82f6", "rgb(59 130 246 / 50%)", "tomato"); anything else throws an error and keeps the old color.
These are the only “profile” properties that we have for each person currently that saves across visits. In the future, we might allow for customizing these (message us if you have suggestions or things you’d use it for!).
<input id="my-name" placeholder="your name" />
<input id="my-color" type="color" />
<ul id="roster"></ul>
<script type="module">
import { playhtml } from "https://unpkg.com/playhtml@latest";
await playhtml.init();
const nameInput = document.getElementById("my-name");
const colorInput = document.getElementById("my-color");
nameInput.value = playhtml.people.me.name ?? "";
colorInput.value = playhtml.people.me.color;
nameInput.addEventListener("change", () => (playhtml.people.me.name = nameInput.value));
colorInput.addEventListener("input", () => (playhtml.people.me.color = colorInput.value));
// Calls back now, then when someone joins, leaves, or changes their name or color
playhtml.people.onChange((everyone) => {
document.getElementById("roster").replaceChildren(
...everyone.map((person) => {
const item = document.createElement("li");
item.textContent = person.name ?? "anonymous";
item.style.color = person.color;
return item;
}),
);
});
</script>
import { playhtml, usePeople } from "@playhtml/react";
function Roster() {
const { everyone, me } = usePeople();
return (
<>
<input
value={me.name ?? ""}
onChange={(e) => (playhtml.people.me.name = e.target.value)}
/>
<input
type="color"
value={me.color}
onChange={(e) => (playhtml.people.me.color = e.target.value)}
/>
<ul>
{everyone.map((person) => (
<li key={person.pid} style={{ color: person.color }}>
{person.name ?? "anonymous"}
</li>
))}
</ul>
</>
);
}Before playhtml has connected, usePeople().me has an empty pid and color.
This is you on this site. Change your name or color and it’s saved for your next visit, and your cursor updates for everyone.
Page presence
Section titled “Page presence”Each person has one presence object for the page. setMyPresence(key, value) sets one key of yours, so separate features can each own a key without overwriting each other. Everyone reads it back on each person in everyone, including you.
// Set one key of your presence (other keys are kept)
playhtml.people.setMyPresence("status", { text: "focused", icon: "target" });
// Read everyone (including you; isMe marks you)
for (const person of playhtml.people.everyone) {
person.name; // "Ana"
person.color; // "#3b82f6"
person.presence.status; // { text, icon } or undefined
}
// Your own
playhtml.people.me.presence.status;
// Fires now, then whenever someone joins, leaves, or changes their status
const unsubscribe = playhtml.people.onChange("status", (everyone) => {
renderStatusList(everyone);
});
import { usePeople } from "@playhtml/react";
function StatusList() {
// Re-renders only when someone joins, leaves, or changes their status
const { everyone, setMyPresence } = usePeople("status");
return (
<>
<button onClick={() => setMyPresence("status", { text: "focused" })}>focus</button>
{everyone.map((person) => (
<span key={person.pid} style={{ color: person.color }}>
{person.presence.status?.text ?? "…"}
</span>
))}
</>
);
}usePeople() returns { everyone, me, setMyPresence }. The same object is on usePlayContext().people, which re-renders whenever anyone’s presence changes.
Pick a status and everyone on this page sees it next to your name. Pick it again to clear it. Open the page in an incognito window to see a second person; close that window and they drop off the list.
Setting and clearing
Section titled “Setting and clearing”playhtml.people.setMyPresence("status", "typing"); // set a key
playhtml.people.setMyPresence("status", null); // remove it
Each key is replaced whole: setMyPresence("status", { icon: "wave" }) replaces your entire status. If two separate features on your page both use presence, give their keys distinct names (chatTyping, lobbyReady) so they don’t collide. cursor is set by playhtml, so you can’t use it as a key.
Presence is for state changes, not per-frame streams. Each person’s page presence writes are rate limited (about 20 per second), and extra writes are dropped. Pointer position is already shared for you by cursors. For scroll position or similar, throttle your writes.
Watching only what you need
Section titled “Watching only what you need”onChange fires when someone joins or leaves, changes their name or color, or changes their presence. Pass a key or a list of keys first to ignore changes to other keys:
playhtml.people.onChange(callback); // any presence change
playhtml.people.onChange("status", callback); // only status
playhtml.people.onChange(["status", "mood"], callback); // either one
playhtml.people.onChange("cursor", callback); // cursor moves too
Cursor moves are skipped unless you pass "cursor", since they happen many times a second. usePeople(keys) takes the same keys.
Element presence
Section titled “Element presence”Use element presence when the signal belongs to one element, like “who has joined this widget” or “which choice is this reader previewing here”. It sits next to the element’s data: give the element a defaultPresence, change yours with setMyPresence, and render from everyone and me.
<button id="ready-count">0 ready</button>
<script type="module">
import { playhtml } from "https://unpkg.com/playhtml@latest";
playhtml.register("ready-count", {
defaultPresence: { ready: false },
onClick: (_event, { me, setMyPresence }) => {
setMyPresence({ ready: !me.presence.ready });
},
update: ({ element, everyone }) => {
const ready = everyone.filter((person) => person.presence.ready).length;
element.textContent = `${ready} of ${everyone.length} ready`;
},
});
playhtml.init();
</script>
<CanPlayElement id="ready-count" defaultData={{}} defaultPresence={{ ready: false }}>
{({ everyone, me, setMyPresence }) => {
const ready = everyone.filter((person) => person.presence.ready).length;
return (
<button onClick={() => setMyPresence({ ready: !me.presence.ready })}>
{ready} of {everyone.length} ready
</button>
);
}}
</CanPlayElement> Each dot is one person with this demo on screen, and the glowing dot is you. The color is your element presence, separate from your saved color: pick one and everyone sees your dot change, but it resets when you leave. Open the page in an incognito window to see a second dot.
On an element:
everyoneis everyone who has that element on screen, each starting at the element’sdefaultPresence. Each person has the samepid,name, andcoloras on the page; onlypresencediffers.meis you, with your presence on this element atme.presence.setMyPresencesets your whole value, likesetData: pass the new value, or a function that edits a copy of yours (setMyPresence((p) => { p.ready = true })). An element owns its presence value, so there are no keys to merge.
You can also read and watch an element’s presence from elsewhere on the page through its handle, with the same names:
const lamp = playhtml.getHandle("lamp");
lamp.everyone; // people with the lamp on screen
lamp.me.presence; // your presence on the lamp
lamp.setMyPresence({ hovering: true });
lamp.onEveryoneChange("hovering", (everyone) => {
badge.textContent = everyone.filter(
(person) => person.presence.hovering,
).length;
});
Presence rooms
Section titled “Presence rooms”Page presence covers everyone on the page. When you want presence scoped to something other than the page (a lobby, a document, a game table that several pages share), create a presence room. room.people has the same API as playhtml.people, except cursors, which stay on the page.
const room = playhtml.createPresenceRoom("lobby-42");
room.people.setMyPresence("status", { text: "ready" });
const unsubscribe = room.people.onChange("status", renderLobby);
// When you're done (e.g. the person leaves the lobby), tear it down:
room.destroy();
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
return (
<button onClick={() => room.people.setMyPresence("ready", true)}>
{room.people.everyone.length} in the lobby
</button>
);
}usePresenceRoom(name) joins the room and tears it down for you on unmount. To re-render when the room changes, subscribe with room.people.onChange in an effect.
Cursors
Section titled “Cursors”Turn on cursors with playhtml.init({ cursors: { enabled: true } }) and everyone sees each other’s pointers, in their colors and with their names. Each person’s cursor is page presence that playhtml fills in for you, at person.presence.cursor ({ x, y, pointer, zone, page }, or missing when their pointer isn’t on the page).
playhtml.people.onChange("cursor", (everyone) => {
for (const person of everyone) {
const { cursor } = person.presence;
if (cursor) drawDot(cursor.x, cursor.y, person.color);
}
});
Cursor moves are skipped by onChange unless you pass "cursor", since they happen many times a second. In React, usePeople("cursor") does the same. Rooms, cursor styles, chat, proximity, and anchoring cursors to a zoomable canvas are covered in Cursors.
Migrating from earlier versions
Section titled “Migrating from earlier versions”Earlier versions split this across playhtml.users, a channel-based playhtml.presence, and element live values. They all still work, but new code should use the names above. playhtml.users.getAll() returns the same people as people.everyone, without presence. window.cursors.color and window.cursors.name also still work as aliases for people.me.color and people.me.name (see the legacy cursor API).
| Before | Now |
|---|---|
playhtml.users.getAll() | playhtml.people.everyone |
playhtml.users.me.name = "Ana" | playhtml.people.me.name = "Ana" |
playhtml.users.onChange(cb) | playhtml.people.onChange(cb) |
playhtml.presence.setMyPresence("status", v) | playhtml.people.setMyPresence("status", v) |
playhtml.presence.getPresences() | playhtml.people.everyone (read person.presence) |
playhtml.presence.onPresenceChange("status", cb) | playhtml.people.onChange("status", cb) |
playhtml.presence.onPresenceChange("cursor", cb) | playhtml.people.onChange("cursor", cb) |
room.presence.setMyPresence(...) | room.people.setMyPresence(...) |
useUsers(), usePresence("status"), usePlayerIdentity() | usePeople(), usePeople("status"), usePeople().me |
element live: {...} / myDefaultAwareness | defaultPresence: {...} |
element live, users: [{ user, live }] | me.presence, everyone (each person’s presence) |
element setLive(v) / setMyAwareness(v) | setMyPresence(v) |
When to use data instead
Section titled “When to use data instead”If the state should still be there after a reload, or for someone who arrives later, use data, not presence. The full decision table, covering element data, page data, presence, cursors, and events, is on core concepts. For the full API, see the people and presence reference.