Skip to content

playhtml client

The playhtml object is the library entry point. Import it from the package or from a CDN.

npm with a bundler:

import { playhtml } from "playhtml";

await playhtml.init();

CDN without a bundler:

<script type="module">
  import { playhtml } from "https://unpkg.com/playhtml";

  await playhtml.init();
</script>

API groups at a glance:

GroupMembers
Lifecycleinit, configure, ready, isLoading, handleNavigation
Element setup & teardownsetupPlayElements, setupPlayElement, setupPlayElementForTag, removePlayElement, deleteElementData
Custom elementsregister, define, getHandle
EventsdispatchPlayEvent, registerPlayEventListener, removePlayEventListener
Page datacreatePageData
People & presencepeople, createPresenceRoom, cursorClient, users and presence (deprecated)
InspectionroomId, host, syncedStore, listSharedElements

Signature: init(options?: InitOptions): Promise<void>

Connects to the room and activates playhtml elements. Resolves when initial sync finishes (same as await playhtml.ready).

import { playhtml } from "playhtml";

await playhtml.init({ room: "my-room" });

Safe to call more than once. Later calls reuse the connection. Options must match the first call; conflicting options warn and are ignored.

See init() options for all options.


Signature: configure(options?: InitOptions): void

Records options without connecting. Use when you cannot call a single init() at the top of the app (islands, multi-page sites, multiple roots).

// In a script that runs early (e.g. in <head>):
playhtml.configure({ cursors: { enabled: true } });

// Anywhere later — these just ensure playhtml is running:
playhtml.init();

configure() only records options; connection does not start until init() is called. If init() has already started, configure() behaves like a late init() call: matching options are accepted, conflicting options warn.

See init() options for all available options.


Type: Promise<void> (read-only)

Resolves when the room has synced and elements are active. Rejects if the connection fails.

When client-side navigation moves to a different room (see handleNavigation()), ready is replaced by a new promise that resolves once the new room has synced. Read playhtml.ready again after navigating rather than holding on to an old one.

playhtml.init();

// Await sync before reading shared state
await playhtml.ready;
console.log("synced:", playhtml.roomId);

Type: boolean (read-only)

true from the time init() is called until the room has synced and elements are active. It turns true again while client-side navigation loads a different room, and back to false once that room is in. Useful for showing a loading indicator.

Each time it flips, playhtml dispatches a playhtml:loading-change event on document with detail.isLoading.

if (playhtml.isLoading) {
  document.body.classList.add("loading");
}
document.addEventListener("playhtml:loading-change", (event) => {
  document.body.classList.toggle("loading", event.detail.isLoading);
});

Signature: handleNavigation(): Promise<void>

Re-derives the room from the URL (or your room function), tears down the old room, and re-scans the DOM. Call after client-side navigation.

router.on("navigate", () => playhtml.handleNavigation());

See Navigation & SPAs for a full guide and framework-specific examples.


Signature: setupPlayElement(element: Element, options?: { ignoreIfAlreadySetup?: boolean }): void

Registers one element after init(). Use for dynamically added built-in or defined capabilities.

Pass { ignoreIfAlreadySetup: true } to skip elements that are already registered.

The element needs a unique id.

For a one-off custom element, use register(elementOrId, initializer). It binds the element automatically, so it does not need a separate setupPlayElement call.

const card = document.createElement("div");
card.id = "card-1";
card.setAttribute("can-move", "");
document.body.appendChild(card);
playhtml.setupPlayElement(card);

Signature: setupPlayElements(): void

Scans the entire document and registers elements that carry a playhtml capability attribute. Elements already bound to the same DOM node are skipped. Called internally at the end of init(). Use it after injecting a batch of playhtml elements into the DOM.

container.innerHTML = serverRenderedHtml;
playhtml.setupPlayElements();

Signature: setupPlayElementForTag(element: HTMLElement, tag: string): Promise<void>

Registers an element for a single specific capability tag. Lower-level than setupPlayElement; only needed when you want to activate one capability on an element that may carry others you do not want to activate yet.

await playhtml.setupPlayElementForTag(el, "can-move");

Signature: removePlayElement(element: Element | null): void

Detaches the element’s handler (listeners, timers, observers) but keeps shared data. Use when the DOM node is removed but may come back.

To delete shared data too, use deleteElementData.

playhtml.removePlayElement(document.getElementById("card-1"));

Signature: deleteElementData(tag: string, elementId: string): void

Deletes shared data for tag + elementId. Propagates to all clients. Also removes the local handler.

playhtml.deleteElementData("can-duplicate", "clone-abc123");

Throws a console warning if called before init() completes sync.


Use register for one custom element and define for a reusable capability. Both accept an ElementInitializer with either the supported imperative update renderer or the experimental declarative view renderer. See Registration API.

Signatures:

register<T, U, V>(
  elementId: string,
  init: ElementInitializer<T, U, V>,
): PlayElementHandle<T, U, V>

register<T, U, V>(
  element: HTMLElement,
  init: ElementInitializer<T, U, V>,
): PlayElementHandle<T, U, V>

Binds an initializer to one element. Pass a string id before the element exists, or pass an existing HTML element to bind that node directly. The element form requires a non-empty id. Both forms return a handle for reads and writes outside the element’s callbacks.

const counter = document.getElementById("my-counter");
const handle = playhtml.register(counter, {
  defaultData: { count: 0 },
  onClick: (_event, { setData }) => {
    setData((data) => {
      data.count += 1;
    });
  },
  update: ({ element, data }) => {
    element.textContent = `Clicked ${data.count} times`;
  },
});

Directly assigning initializer fields to an element remains supported for compatibility, but is deprecated for vanilla code.


Signature: define<T, U, V>(capabilityName: string, init: ElementInitializer<T, U, V>): void

Registers a reusable capability under an attribute name. Matching elements already on the page bind immediately, as do matching descendants rendered by a registered view. Call setupPlayElement(element) for other elements added after initialization. define is the runtime equivalent of init({ extraCapabilities }).

playhtml.define("can-note", {
  defaultData: { text: "" },
  view: ({ data, setData }) => html`
    <textarea @input=${(e) => setData({ text: e.target.value })}>${data.text}</textarea>
  `,
});

Throws if capabilityName collides with a built-in capability name.


Signature: getHandle(elementId: string, capability?: string): PlayElementHandle

Returns a handle for any bound element. When one element carries multiple capabilities, pass the capability name (e.g. "can-toggle") to disambiguate.

const handle = playhtml.getHandle("card-1", "can-move");
console.log(handle.getData()); // { x: 120, y: 45 }

Events let you broadcast custom actions across all connected clients in the room. See Events for the full guide.

Signature: registerPlayEventListener(type: string, event: Omit<PlayEvent, "type">): string

Registers a listener for a custom event type. Returns a unique string id that you can pass to removePlayEventListener to clean up later.

// PlayEvent shape:
interface PlayEvent<T = any> {
  type: string;
  onEvent: (eventPayload: T) => void; // the payload passed to dispatchPlayEvent
}
const listenerId = playhtml.registerPlayEventListener("confetti", {
  onEvent: () => window.confetti({ particleCount: 100 }),
});

Multiple listeners for the same type are allowed — each gets its own id.


Signature: dispatchPlayEvent(message: EventMessage): void

Broadcasts an event to all connected clients, including the sender. The type must already be registered via registerPlayEventListener (or the events init option).

// EventMessage shape:
type EventMessage<T = any> = { type: string; eventPayload?: T };
playhtml.dispatchPlayEvent({ type: "confetti" });
// With a payload:
playhtml.dispatchPlayEvent({ type: "highlight", eventPayload: { elementId: "card-1" } });

Signature: removePlayEventListener(type: string, id: string): void

Removes the listener identified by type and the id returned from registerPlayEventListener.

playhtml.removePlayEventListener("confetti", listenerId);

Page data lets you read and write named shared values that are not tied to any DOM element — good for page-wide settings, counters, or leaderboards.

See Page data for a full guide.

Signature: createPageData<T>(name: string, defaultValue: T): PageDataChannel<T>

Creates (or re-opens) a named shared data channel in the current room. Must be called after playhtml.ready resolves.

await playhtml.ready;

const views = playhtml.createPageData("viewCount", 0);
views.setData((n) => n + 1);
views.onUpdate((n) => {
  document.getElementById("views").textContent = String(n);
});

PageDataChannel<T> methods:

MethodDescription
getData(): TReturns the current value.
setData(data: T | updater): voidUpdates the value. Mutate object/array drafts in place; an updater for a primitive returns its next value.
onUpdate(callback: (data: T) => void): () => voidSubscribes to changes. Synchronous writes produce one callback with the final value. Returns an unsubscribe function.
destroy(): voidTears down the channel and removes all subscriptions.

Throws if called before init() completes sync.


people is who’s on the page and what each of them is doing right now. See People & presence for types and API details. Usage guide: People and presence.

Type: People

Everyone on the page, including you, as Person objects (pid, name, color, isMe, presence), whether or not cursors are enabled. This is the place to read or set your name and color, and to set your page presence. Throws if accessed before init() completes.

await playhtml.ready;

playhtml.people.me.name = "Alice";
playhtml.people.setMyPresence("status", "typing");

playhtml.people.everyone; // [{ pid, name, color, isMe, presence }, ...]
const unsubscribe = playhtml.people.onChange("status", (everyone) => {
  renderStatusRow(everyone);
});
MemberDescription
everyoneEveryone here (including you), in join order. getAll() returns the same list.
meYou. Assign me.name or me.color to change them for everyone. me.presence is your page presence.
setMyPresence(key, value)Sets one key of your presence. null removes it.
onChange(keys?, callback)Calls back right away and on joins, leaves, name or color changes, and presence changes (only the given keys, if any). Cursor moves only with "cursor". Returns an unsubscribe function.

Signature: createPresenceRoom(name: string): PresenceRoom

Creates a separate presence scope (sidebar, lobby, shared document). room.people has the same API as people, without cursors. Call room.destroy() when done.

await playhtml.ready;

const sidebar = playhtml.createPresenceRoom("sidebar");
sidebar.people.setMyPresence("focus", { section: "comments" });

// Tear down when done
sidebar.destroy();

Throws if called before init() completes sync.


Type: { me, getAll(), onChange(cb) }

The earlier identity API. It keeps working: users.me is the same as people.me without presence, and users.getAll() returns everyone without presence. Use people.


Type: PresenceAPI

The earlier channel-based presence object. It keeps working, but use people. See the migration table.


Type: CursorClientAwareness | null

Low-level cursor client, or null if cursors are disabled. Most config belongs in init({ cursors: … }), and cursor positions are easier to read from each person’s presence.cursor in people. See Cursors.


Read-only debug surfaces. Do not mutate them directly.

Type: string (read-only)

The normalised room id the client is currently connected to (hostname-prefixed). Empty string before init().

console.log(playhtml.roomId); // e.g. "example.com-/my-page"

Type: string (read-only)

The PartyKit host the client is currently connected to. Useful for debugging custom host configurations.


Signature:

listSharedElements(): Array<{
  type: "source" | "consumer";
  elementId: string;
  dataSource: string;
  normalized: string;
  permissions?: "read-only" | "read-write";
  element: HTMLElement;
}>

Returns a list of every shared-element source and consumer currently registered in the page. Useful for debugging shared / data-source wiring.

See Shared elements for the full guide.


Type: ReadOnlyStore<PlayStore["play"]> (read-only)

A read-only view into the underlying synced data store, keyed by capability tag then element id. Useful for inspecting the raw shared state of all elements in the devtools console. Do not write to this object — mutations will not be validated or synced correctly. Use setData from an element handler instead.