Skip to content

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, at person.presence.cursor.
  • On an element: inside an element, everyone is 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.

Each person’s pid, name, and color are saved, and they work whether or not cursors are on.

  • pid is a stable id for this person in this browser. Use it as a React key or to match someone across features.
  • name is whatever they’ve set, or undefined.
  • color is 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>
View code

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.

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);
});

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.

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.

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.

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>

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:

  • everyone is everyone who has that element on screen, each starting at the element’s defaultPresence. Each person has the same pid, name, and color as on the page; only presence differs.
  • me is you, with your presence on this element at me.presence.
  • setMyPresence sets your whole value, like setData: 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;
});

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.

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.

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).

BeforeNow
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: {...} / myDefaultAwarenessdefaultPresence: {...}
element live, users: [{ user, live }]me.presence, everyone (each person’s presence)
element setLive(v) / setMyAwareness(v)setMyPresence(v)

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.