Custom elements
The built-in capabilities (can-move, can-toggle, …) cover the common interactions. When you need your own state — a counter, a poll, a guestbook, a chat — you build a custom element. There are two approaches:
can-mirror: The DOM holds the state. Changes on the mirrored element sync automatically: attributes, its direct child list, and form or contenteditable state from input/change events. Nested elements do not recursively mirror arbitrary DOM mutations unless you putcan-mirroron those nested elements too.can-play: You define shared data shapes that you use to update how the element shows up and behaves. You have full control over how the data affects what happens, which data should be persisted and which are only needed for real-time interactions.
This page covers both. Vanilla HTML has the full range — can-mirror, can-play, and an experimental reactive view API. React has a single, declarative path: withSharedState. Pick your framework below.
This page runs a few live can-play demos in its margins — a doodle strip here, a guestbook and a prize wheel further down, and a tally on every code block you copy from. Open the page in a second tab to watch them sync.
Draw a tiny 64×64 face. It joins the strip for everyone reading this page. Capped at 20.
Vanilla HTML
Section titled “Vanilla HTML”Vanilla has three tools, from least code to most: can-mirror syncs the DOM as-is, can-play gives you a data model that you update the DOM from, and the experimental reactive view API renders the DOM from that data for you.
can-mirror
Section titled “can-mirror”can-mirror syncs changes on the element you put it on: its attributes, its direct child list, and form or contenteditable state that surfaces through input/change events. It does not watch arbitrary descendant DOM mutations. You don’t write any data or callbacks. Just add can-mirror and an id, then change that element with normal HTML, CSS, and JavaScript.
Use it when the state you want to share is the DOM itself: a shared textarea, a form, or a growing list of direct children. If a nested list, card, or editor should own its own changes, put can-mirror on that nested element too and give it a stable id.
Example: an emoji-only textarea
Section titled “Example: an emoji-only textarea”A textarea that filters to emoji characters on input. The filtered value mirrors to everyone.
Type anything. Only emojis stick, and the filtered value mirrors to everyone.
<textarea can-mirror id="emoji-pad" rows="4" placeholder="emojis only..."></textarea>
<script>
const emojiOnly = /\p{Extended_Pictographic}/gu;
document.getElementById("emoji-pad").addEventListener("input", (e) => {
const match = e.target.value.match(emojiOnly);
e.target.value = match ? match.join("") : "";
});
</script>
Example: a list you can add children to
Section titled “Example: a list you can add children to”Click ”+” to append a new <li>. The list itself has can-mirror, so the direct child addition mirrors and new readers see the whole list, not just the latest.
- first
<ul can-mirror id="guestbook">
<li>first</li>
</ul>
<button onclick="
document.getElementById('guestbook').appendChild(
Object.assign(document.createElement('li'), { textContent: new Date().toLocaleTimeString() })
);
">add entry</button>
If a mirrored child also has its own playhtml capability, keep giving it a stable id. The element that creates it locally can still call playhtml.setupPlayElement(child) right after insertion; copies that arrive through can-mirror are wired automatically. If that child should mirror its own nested DOM changes later, put can-mirror on the child too.
Hover, focus, form inputs, contenteditable, programmatic attribute changes, direct child changes, and nested opt-in elements: one page with a live demo for each. Open in two tabs to watch it sync.
can-play
Section titled “can-play”You give the element a defaultData, handlers like onClick, and an updateElement callback that writes the DOM from the current data. Set those properties on the element before playhtml.init() runs. playhtml syncs the data and calls updateElement whenever it changes — locally or from another tab.
The Element API reference has the full annotated property list — ctx is defined once at the top.
Most capability tags can share the same element. Each tag keeps its own data and behavior, so an element can be both custom and draggable with <img can-play can-move id="candle">. Watch out for style conflicts: two capabilities can still fight if they write the same CSS property. For example, can-move and can-spin both update transform, so the last update wins instead of combining translate and rotate automatically.
A click counter
Section titled “A click counter”<div id="counter" can-play></div>
<script type="module">
import { playhtml } from "playhtml";
const el = document.getElementById("counter");
el.defaultData = { count: 0 };
el.onClick = (_e, { setData }) => setData((d) => { d.count += 1; });
el.updateElement = ({ element, data }) => {
element.textContent = `${data.count}`;
};
playhtml.init();
</script>
setData takes either a mutator that edits a draft in place (setData(d => { d.count += 1 }), merge-friendly across clients — preferred for counters, lists, nested fields) or a replacement value (setData({ count: 5 }), last-write-wins). See Data essentials for the merge rules.
Click to add one. The count is shared — everyone reading this page bumps the same number.
State to style
Section titled “State to style”updateElement runs on every change, so the simplest pattern is “read data, set a class or style.”
<button id="switch" can-play>off</button>
<script type="module">
import { playhtml } from "playhtml";
const el = document.getElementById("switch");
el.defaultData = { on: false };
el.onClick = (_e, { setData }) => setData((d) => { d.on = !d.on; });
el.updateElement = ({ element, data }) => {
element.classList.toggle("on", data.on);
element.textContent = data.on ? "on" : "off";
};
playhtml.init();
</script>
Click to flip it on/off for everyone.
Presence and per-user state
Section titled “Presence and per-user state”myDefaultAwareness seeds a per-user ephemeral value; setMyAwareness broadcasts it; updateElementAwareness renders everyone’s. Awareness does not persist — it’s “who’s here right now.”
<div id="online" can-play></div>
<script type="module">
import { playhtml } from "playhtml";
const el = document.getElementById("online");
el.myDefaultAwareness = "#2563eb";
el.updateElementAwareness = ({ element, awareness }) => {
element.replaceChildren(
...awareness.map((color) => {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = color;
return dot;
}),
);
};
playhtml.init();
</script>
Everyone online shows up as a colored dot — yours is highlighted.
Reset shortcut
Section titled “Reset shortcut”Set resetShortcut so shift-clicking (or ctrl/alt/meta) resets the element to its defaultData for everyone:
el.resetShortcut = "shiftKey";
Dynamically-added elements
Section titled “Dynamically-added elements”playhtml.init() scans the DOM once. For elements you add later, call playhtml.setupPlayElement(el) after inserting them (and use selector-id for many-of-a-kind elements). See Dynamic elements.
Reactive view API (experimental)
Section titled “Reactive view API (experimental)”Instead of hand-writing DOM updates in updateElement, you can register a view: a pure function from state to a lit-html template. playhtml patches only what changed when the data updates. It’s the vanilla equivalent of what React’s withSharedState already gives you.
import { playhtml, html, svg, repeat, classMap, styleMap, nothing } from "playhtml";
register + view
Section titled “register + view”The element is an empty mount point; everything you see is rendered from state. register is callable before or after init() and before or after the element exists.
<div id="counter" can-play></div>
<script type="module">
import { playhtml, html } from "playhtml";
playhtml.register("counter", {
defaultData: { count: 0 },
view: ({ data, setData }) => html`
<button @click=${() => setData((d) => { d.count += 1; })}>
${data.count}
</button>
`,
});
playhtml.init();
</script>
register returns a handle (getElement, getData, setData, setLocalData, setMyAwareness, requestUpdate, unregister) for reads/writes from outside the view.
Local UI state
Section titled “Local UI state”Per-user, un-synced state (a panel toggle, a text draft) goes in localData; setLocalData re-renders the view.
playhtml.register("panel", {
defaultData: {},
defaultLocalData: { open: false },
view: ({ localData, setLocalData }) => html`
<button @click=${() => setLocalData((d) => { d.open = !d.open; })}>
${localData.open ? "hide" : "show"}
</button>
${localData.open ? html`<div class="body">…</div>` : nothing}
`,
});
This is where the view API earns its keep — repeat(items, keyFn, template) is lit-html’s keyed list, versus hand-diffing DOM in updateElement. Key by a stable unique id (crypto.randomUUID()), never an index or timestamp.
const guestbook = playhtml.register("guestbook", {
defaultData: { entries: [] },
view: ({ data }) => html`
<ul>
${repeat(data.entries, (e) => e.id, (e) => html`<li>${e.text}</li>`)}
</ul>
`,
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = new FormData(e.target).get("text");
guestbook.setData((d) => {
d.entries.unshift({ id: crypto.randomUUID(), text });
});
e.target.reset();
});
Drop a note about what you’re learning or building. The list is shared, capped at 20, oldest entries fall off.
Reusing the element many times: define
Section titled “Reusing the element many times: define”playhtml.define(name, init) registers a capability under an attribute, so every [name] element gets it (including ones added later).
<div id="note-1" can-note data-color="yellow"></div>
<div id="note-2" can-note data-color="pink"></div>
<script type="module">
import { playhtml, html } from "playhtml";
playhtml.define("can-note", {
defaultData: (el) => ({ text: "", color: el.dataset.color }),
view: ({ data, setData }) => html`
<textarea
class="note note--${data.color}"
.value=${data.text}
@input=${(e) => setData((d) => { d.text = e.target.value; })}
></textarea>
`,
});
playhtml.init();
</script>
Clock-driven views: requestUpdate
Section titled “Clock-driven views: requestUpdate”A view re-renders on state change — but a running timer’s display changes every frame while its data doesn’t. Call requestUpdate() from an onMount loop. Return the cleanup so the loop stops when the element goes away.
playhtml.register("timer", {
defaultData: { startAt: 0, running: false },
view: ({ data }) => {
const elapsed = data.running ? Date.now() - data.startAt : 0;
return html`<span>${(elapsed / 1000).toFixed(1)}s</span>`;
},
onMount: ({ getData, requestUpdate }) => {
let raf;
const tick = () => {
if (getData().running) requestUpdate();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
},
});
Spin to pick a docs page to read next. The wheel’s spin seed is shared, so everyone here sees it land on the same label.
Composition: views that render other elements
Section titled “Composition: views that render other elements”A view can render mount points for other custom elements, and playhtml binds them as they appear (and tears them down when a keyed list removes them, preserving their shared data). A chat app is the classic case: a parent renders one room per name, each room its own independently-synced element.
playhtml.define("can-chat", {
defaultData: { messages: [] },
view: ({ data, element }) => html`
<strong>${element.dataset.name}</strong>
${repeat(data.messages, (m) => m.id, (m) => html`<div>${m.text}</div>`)}
`,
});
playhtml.register("chats", {
defaultData: { rooms: ["general"] },
view: ({ data }) => html`
${repeat(data.rooms, (n) => n, (n) =>
html`<div id="chat-${n}" can-chat data-name=${n}></div>`)}
`,
});
Rules for vanilla capability children: render them keyed and empty (let the child own its contents), and give each a stable id. Adding one binds it; removing it from a keyed list tears its handler down while preserving its shared data, so re-adding the same id rehydrates it.
Rules and gotchas (view API)
Section titled “Rules and gotchas (view API)”- Renders must be pure. Don’t call
setData/setLocalData/setMyAwarenesswhile rendering — it’s an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from@eventhandlers oronMount. - Key lists by a stable id, never index or timestamp.
- Security: interpolated text and attribute values are auto-escaped (and
unsafeHTMLis deliberately not exported). Two sharp edges remain about a binding’s content:stylefrom a user-controlled string is CSS-injectable (preferstyleMap), andhref/srcfrom user data isn’t URL-sanitized (validate the scheme). - Changing the shape of already-live data needs a migration or a new field name — see Data essentials.
In React there’s a single, declarative path: withSharedState. It wraps a component, owns a piece of shared data, and hands the component data + setData. You render JSX from data; playhtml syncs it and re-renders on every change, local or remote. There’s no updateElement to hand-write and no separate view API — JSX is the declarative view.
A click counter
Section titled “A click counter”import { withSharedState } from "@playhtml/react";
export const Counter = withSharedState(
{ defaultData: { count: 0 } },
({ data, setData }) => (
<button onClick={() => setData((d) => { d.count += 1; })}>
{data.count}
</button>
),
);
setData takes either a mutator that edits a draft in place (setData(d => { d.count += 1 }), merge-friendly across clients — preferred for counters, lists, nested fields) or a replacement value (setData({ count: 5 }), last-write-wins). See Data essentials for the merge rules.
State to style
Section titled “State to style”The component re-renders on every change, so the simplest pattern is “read data, set a class or style.”
export const Switch = withSharedState(
{ defaultData: { on: false } },
({ data, setData }) => (
<button
className={data.on ? "is-on" : "is-off"}
onClick={() => setData((d) => { d.on = !d.on; })}
>
{data.on ? "on" : "off"}
</button>
),
);
Local UI state
Section titled “Local UI state”Per-user, un-synced state (a panel toggle, a text draft) is plain React useState — keep it out of the shared data.
import { useState } from "react";
export const Panel = withSharedState({ defaultData: {} }, () => {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((o) => !o)}>{open ? "hide" : "show"}</button>
{open && <div className="body">…</div>}
</>
);
});
Render the collection straight from data. Key by a stable unique id (crypto.randomUUID()), never an index or timestamp.
export const Guestbook = withSharedState(
{ defaultData: { entries: [] } },
({ data, setData }) => {
const [text, setText] = useState("");
const submit = (e) => {
e.preventDefault();
setData((d) => { d.entries.unshift({ id: crypto.randomUUID(), text }); });
setText("");
};
return (
<>
<ul>{data.entries.map((e) => <li key={e.id}>{e.text}</li>)}</ul>
<form onSubmit={submit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
</form>
</>
);
},
);
Presence and per-user state
Section titled “Presence and per-user state”myDefaultAwareness seeds a per-user ephemeral value; setMyAwareness broadcasts it; the awareness prop renders everyone’s. Awareness does not persist — it’s “who’s here right now.”
export const Online = withSharedState(
{ defaultData: {}, myDefaultAwareness: "#2563eb" },
({ awareness }) => (
<div className="row">
{awareness.map((color, i) => (
<span key={i} className="dot" style={{ background: color }} />
))}
</div>
),
);
Reusing the component many times
Section titled “Reusing the component many times”Render the component as many times as you like — give each a stable id. defaultData can be a function of the component’s props.
const Note = withSharedState(
({ color }) => ({ defaultData: { text: "", color } }),
({ data, setData }) => (
<textarea
className={`note note--${data.color}`}
value={data.text}
onChange={(e) => setData((d) => { d.text = e.target.value; })}
/>
),
);
// render as many as you want — give each a stable id
<Note id="note-1" color="yellow" />
<Note id="note-2" color="pink" />
Clock-driven views
Section titled “Clock-driven views”A component re-renders on state change — but a running timer’s display changes every frame while its data doesn’t. Drive a render loop with a useState tick. Return the cleanup so the loop stops when the element goes away.
export const Timer = withSharedState(
{ defaultData: { startAt: 0, running: false } },
({ data }) => {
const [, tick] = useState(0);
useEffect(() => {
let raf;
const loop = () => {
if (data.running) tick((n) => n + 1); // force a re-render
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [data.running]);
const elapsed = data.running ? Date.now() - data.startAt : 0;
return <span>{(elapsed / 1000).toFixed(1)}s</span>;
},
);
Composition: components that render other elements
Section titled “Composition: components that render other elements”A component can nest other withSharedState components, each with its own independently-synced data. A chat app is the classic case: a parent renders one room per name, each room its own element.
const Chat = withSharedState(
({ name }) => ({ defaultData: { messages: [] } }),
({ data }, { name }) => (
<>
<strong>{name}</strong>
{data.messages.map((m) => <div key={m.id}>{m.text}</div>)}
</>
),
);
const Chats = withSharedState(
{ defaultData: { rooms: ["general"] } },
({ data }) => data.rooms.map((n) => <Chat key={n} id={`chat-${n}`} name={n} />),
);
Reference
Section titled “Reference”- Every
ElementInitializerproperty: Element API (can-play) - Vanilla view API:
register/define/view - The
playhtmlobject (setup, events, page data): playhtml client - Presence & identity: reference
- React:
@playhtml/reactAPI