Skip to content

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 put can-mirror on 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.

View code

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

A textarea that filters to emoji characters on input. The filtered value mirrors to everyone.

<textarea id="emoji-pad" rows="4" placeholder="emojis only..." can-mirror></textarea>

<script type="module">
  const emojiOnly = /\p{Extended_Pictographic}/gu;
  const emojiPad = document.getElementById("emoji-pad");

  // Drop anything that isn't an emoji. can-mirror shares the filtered value.
  emojiPad.addEventListener("input", () => {
    emojiPad.value = (emojiPad.value.match(emojiOnly) ?? []).join("");
  });
</script>

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.

<ul id="guestbook" can-mirror>
  <li>first</li>
</ul>
<button id="guestbook-add">add entry</button>

<script type="module">
  // Appending a direct child is enough: can-mirror shares it with everyone.
  document.getElementById("guestbook-add").addEventListener("click", () => {
    const item = document.createElement("li");
    item.textContent = new Date().toLocaleTimeString();
    document.getElementById("guestbook").append(item);
  });
</script>

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.

PlaygroundSee every can-mirror edge case →

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.

Register an element with its starting data, event handlers, and renderer in one initializer object. Pass the element itself when you already have it, or pass its id before it exists. Both playhtml.register(element, initializer) and playhtml.register(id, initializer) work before or after playhtml.init(). playhtml calls update whenever shared data or someone’s presence on the element changes.

The Element API reference lists every initializer field and callback context.

Most capabilities can share the same element. Each keeps its own data and behavior, so a registered element can also carry can-move. 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.

<div id="counter"></div>

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

  playhtml.register("counter", {
    defaultData: { count: 0 },
    onClick: (_event, { setData }) => {
      setData((data) => {
        data.count += 1;
      });
    },
    update: ({ element, data }) => {
      element.textContent = String(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 best practices for the merge rules.

Click to add one. The count is shared — everyone reading this page bumps the same number.

update runs on every change, so the simplest pattern is “read data, set a class or style.”

<button id="switch">off</button>

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

  playhtml.register("switch", {
    defaultData: { on: false },
    onClick: (_event, { setData }) => {
      setData((data) => {
        data.on = !data.on;
      });
    },
    update: ({ 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 is each person’s own value for this element. Everyone can see it, only they can change it, and it’s gone when they leave.

  • defaultPresence is the starting value.
  • everyone is everyone with the element on screen, each with pid, name, color, isMe, and their presence.
  • me is you, and setMyPresence sets your value.

The same update function renders both shared data and presence changes. See people and presence.

<div id="online"></div>

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

  playhtml.register("online", {
    defaultPresence: { status: "here" },
    update: ({ element, everyone }) => {
      element.replaceChildren(
        ...everyone.map((person) => {
          const dot = document.createElement("span");
          dot.className = person.isMe ? "dot me" : "dot";
          dot.style.background = person.color;
          dot.title = person.name ?? "Anonymous";
          return dot;
        }),
      );
    },
  });

  playhtml.init();
</script>

Everyone online shows up as a colored dot — yours is highlighted.

View code

Put resetShortcut in the initializer so shift-clicking (or ctrl/alt/meta) resets the element to its defaultData for everyone:

playhtml.register("counter", {
  defaultData: { count: 0 },
  resetShortcut: "shiftKey",
  update: ({ element, data }) => {
    element.textContent = String(data.count);
  },
});

register can run before the element exists and binds it when it appears. For dynamically added built-in or defined capabilities, call playhtml.setupPlayElement(element) after insertion. Use selector-id for many-of-a-kind elements.

Instead of hand-writing DOM updates in update, 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";

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"></div>

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

  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, everyone, me, setMyPresence, onEveryoneChange, requestUpdate, unregister) for reads/writes from outside the view.

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

playhtml.define(name, init) registers a capability under an attribute. 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.

<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 "https://unpkg.com/playhtml@latest";
  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>

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.

View code

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.

  • Renders must be pure. Don’t call setData / setLocalData / setMyPresence while rendering — it’s an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from @event handlers or onMount.
  • Key lists by a stable id, never index or timestamp.
  • Security: interpolated text and attribute values are auto-escaped (and unsafeHTML is deliberately not exported). Two sharp edges remain about a binding’s content: style from a user-controlled string is CSS-injectable (prefer styleMap), and href/src from 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 best practices.

In React, use withSharedState. It wraps a component and passes it shared data, plus everyone, me, and setMyPresence for presence on the element. The component re-renders on every data or presence change, so there’s no update function or separate view API to write.

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 best practices for the merge rules.

▶ Live demo ↑

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

▶ Live demo ↑

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

▶ Live demo ↑

defaultPresence is your starting value, setMyPresence sets yours, and everyone lists each person with their name, color, and presence on this element.

export const Online = withSharedState(
  { defaultData: {}, defaultPresence: { ready: false } },
  ({ everyone, me, setMyPresence }) => (
    <div className="row">
      {everyone.map((person) => (
        <span
          key={person.pid}
          className="dot"
          style={{ background: person.color, opacity: person.presence.ready ? 1 : 0.4 }}
        />
      ))}
      <button onClick={() => setMyPresence({ ready: !me.presence.ready })}>
        {me.presence.ready ? "not ready" : "ready"}
      </button>
    </div>
  ),
);

▶ Live demo ↑

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" />

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

▶ Live demo ↑

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