Skip to content

Capabilities reference

Capabilities are HTML attributes. Add one to an element, give it a stable unique id, call playhtml.init(), and playhtml handles sync and event wiring.

The id is the storage key — it must match across visitors. Use a hand-written id="my-lamp". Without one, playhtml falls back to an HTML hash that can differ across browsers and break sync. For many same-template elements, use selector-id instead — see Dynamic elements.

For demos, see the Capabilities guide. For React wrappers, see React API.


AttributeWhat it doesShared data shapePersistentReset shortcut
can-moveDrag to reposition{ x, y } (numbers)YesShift-click
can-spinDrag to rotate{ rotation } (degrees)YesShift-click
can-toggleClick to flip on/off{ on: boolean }YesShift-click
can-growClick to scale up/down{ scale } (multiplier)YesShift-click
can-duplicateClick to clone a template elementstring[] of clone idsYes
can-hoverSurfaces hover state from any visitornone (presence only)No
can-mirrorSyncs attributes, child list, and form stateelement state snapshotYes
can-playBuild your own capabilityyou define ityou define itoptional

Makes an element draggable. Position persists and syncs — everyone sees the same position.

Guide page: can-move

Type: { x: number; y: number }
Default: { x: 0, y: 0 }

Both values are rounded to one decimal place before being written to shared state (Math.round(n * 10) / 10), keeping synced values compact over the wire.

Sets element.style.transform = "translate(Xpx, Ypx)" on every update.

Without can-move-bounds, the element is constrained to the viewport: if the element is already partially outside the viewport edge, dragging further in that direction is blocked. The element can still be moved freely along axes where it is within the viewport.

Type: element reference (bare id, #id, or CSS selector)
Default: none

Constrains the drag area to the referenced container. Accepts three forms:

can-move-bounds="my-arena"      <!-- bare id -->
can-move-bounds="#my-arena"     <!-- id with # prefix -->
can-move-bounds=".arenas"       <!-- CSS selector — first match wins -->

An id lookup is tried first; if that fails, the value is used as a CSS selector.

The cursor can move past the edge; only the element’s position is clamped.

On mount, playhtml re-clamps stored positions that now fall outside the container.

Type: number (fraction, 0–1)
Default: 1

The fraction of the element that must remain inside the bounds container on each axis. 1 (the default) keeps the entire element inside. Lower values allow partial overhang.

<!-- Let the element hang up to halfway outside -->
<div can-move can-move-bounds="arena"
     can-move-bounds-min-visible="0.5"
     id="sticker">🧲</div>

Set to 0 to drop the fraction constraint entirely (the pixel floor still applies unless you also zero that out).

Type: number (pixels, ≥ 0)
Default: 60

Pixel floor on the keep-visible slice when partial overhang is allowed. Stops the element from sliding fully out of reach.

Effective slice on each axis:

max(minVisible × elementSize, minVisiblePx)

…capped at the full element size. A 100 px element with min-visible="0.3" and min-visible-px="60" yields a 60 px slice (the fraction gives 30, the floor gives 60, so the floor wins).

To allow fully off-screen movement, set both to 0:

<div can-move can-move-bounds="arena"
     can-move-bounds-min-visible="0"
     can-move-bounds-min-visible-px="0"
     id="ghost">👻</div>

Shortcut: shift-click
Resets position to { x: 0, y: 0 } for all visitors in the room.


Makes an element rotatable by dragging horizontally. Rotation persists and syncs.

Guide page: can-spin

Type: { rotation: number }
Default: { rotation: 0 }

rotation is in degrees. Positive values are clockwise.

Sets element.style.transform = "rotate(Ndeg)" on every update. See the transform conflict note under can-move.

Drag the element horizontally. Dragging right increases rotation; dragging left decreases it. The rotation increment scales with the absolute horizontal distance moved (multiplied by 2).

Shortcut: shift-click
Resets rotation to 0 for all visitors in the room.


Turns click into a shared boolean switch. State persists and syncs.

Guide page: can-toggle

Type: { on: boolean }
Default: { on: false }

Toggles the toggled CSS class on the element.

/* Style the on state */
#my-lamp.toggled {
  opacity: 1;
  filter: brightness(1.4);
}

Click the element to flip on between true and false.

Shortcut: shift-click
Resets to { on: false } for all visitors in the room.


Click to scale an element up; alt-click to scale it down. Scale persists and syncs.

Guide page: can-grow

Type: { scale: number }
Default: { scale: 1 }

scale is a CSS scale multiplier (1 = natural size, 2 = double, 0.5 = half).

The maximum scale (2 by default) and hover tracking are kept in local (non-synced) state and are not persisted. All visitors share the same scale value, but each client holds its own maxScale cap of 2.

Sets element.style.transform = "scale(N)" on every update. See the transform conflict note under can-move.

ActionEffectLimit
Clickscale += 0.1Capped at maxScale (default 2)
Alt-clickscale -= 0.1Floored at 0.5

When hovering over the element, the cursor changes to indicate the available action:

  • Grow available: shower / spray cursor 🚿
  • Shrink available (alt held): scissors cursor ✂️
  • At maximum: not-allowed
  • At minimum (alt held): not-allowed

The cursor also updates dynamically as you hold or release the alt key while hovering.

Shortcut: shift-click
Resets to { scale: 1 } for all visitors in the room.


Clicking the element clones a template into a new persistent element. All clones are shared state — everyone sees the same set of clones.

Guide page: can-duplicate

The attribute value names the template element to clone. It accepts the same three forms as can-move-bounds:

<img id="bunny-template" src="/bunny.png" alt="" />
<button can-duplicate="bunny-template" id="clone-btn">add bunny</button>

An id lookup is tried first; if that fails the value is used as a CSS selector.

Type: string[] — array of clone ids
Default: []

Each clone id is generated as <templateId>-<random> where templateId is the template element’s actual .id property (not the raw attribute string). This means #my-template and my-template both yield ids prefixed my-template-.

Clicking the element appends a new clone id to the shared array. All clients react to the change, clone the template node, inject it into the page, and call playhtml.setupPlayElement() on the clone so it becomes a live playhtml element with its own shared state.

Type: element reference (bare id, #id, or CSS selector)
Default: none

When set, every clone is appended to this container element instead of being inserted after the template (or the previous clone).

<div id="bunny-pen"></div>
<button can-duplicate="bunny-template"
        can-duplicate-to="#bunny-pen"
        id="clone-btn">add bunny</button>

To delete a clone and remove it from shared state, call playhtml.deleteElementData("can-duplicate", cloneId) and remove the element from the DOM:

document.querySelectorAll("[id^='bunny-template-']").forEach((el) => {
  playhtml.deleteElementData("can-duplicate", el.id);
  el.remove();
});

No built-in reset shortcut. Deleting all clones (as above) is the equivalent.


Surfaces hover state from any visitor on the page. This is presence-only — state is not persisted and clears when a user leaves.

Guide page: can-hover

can-hover stores no persistent shared data. Hover state is transmitted as element awareness: an ephemeral, per-connection signal scoped to this element.

Awareness shape (per visitor): { hover: boolean }

While any visitor is hovering the element, playhtml sets a data-playhtml-hover attribute on it (an empty-string attribute). When nobody is hovering, the attribute is removed.

Style the hover effect by targeting this attribute:

/* Fires for everyone on the page when anyone hovers */
#my-card[data-playhtml-hover] {
  box-shadow: 0 0 0 3px #6366f1;
  transform: scale(1.03);
}

No reset shortcut. Hover awareness clears automatically when each visitor stops hovering or disconnects.


Syncs an element’s attributes, direct child list, and form / contenteditable state across all visitors. Edits made by any visitor are reflected on all others in real time. State is persistent.

Full treatment with live demos: Custom elements → can-mirror
Interactive examples: Mirror playground

KindSynced
Element attributesYes (excluding ephemeral playhtml-managed ones)
Direct children (child list and text nodes)Yes
Form state: <input> value / checked, <textarea> value, <select> selectedIndexYes, via input and change events
Contenteditable contentYes, via input and change events
Descendant mutations (arbitrary depth)Nocan-mirror only observes the element itself, not its subtree

Ephemeral attributes (data-playhtml-hover, data-playhtml-focus) are not stored in the shared snapshot. They are driven by awareness instead.

Type: ElementState — an internal snapshot of the element’s attributes, children, and form state at mount time. The shape is an implementation detail; interact with it only by reading the live DOM.

can-mirror uses subtree: false on its MutationObserver — it does not recursively watch descendants. To sync mutations inside a child element, add a separate can-mirror attribute (and a stable id) to that child:

<div can-mirror id="outer">
  <ul can-mirror id="inner-list">
    <li>item</li>
  </ul>
</div>

Presence: data-playhtml-hover and data-playhtml-focus

Section titled “Presence: data-playhtml-hover and data-playhtml-focus”

can-mirror also tracks focus state via awareness. When any visitor focuses a descendant of the element, data-playhtml-focus is set on the element for all visitors. Style accordingly:

#my-editor[data-playhtml-focus] {
  outline: 2px solid #6366f1;
}

No reset shortcut.


Define your own shared data and how the element renders (updateElement or experimental view). Use it for counters, guestbooks, games, and anything the built-ins do not cover.

Guide: Custom elements
Reference: Element API · View API


Every element that carries a capability attribute must have a stable, unique id. The id is the storage key — changing it between deployments loses the element’s synced state. Omitting it causes playhtml to fall back to an HTML hash of the element, which can differ across browsers.

<!-- Good: explicit, stable id -->
<img can-move id="hat-magnet" src="/hat.png" alt="" />

<!-- Risky: id-less; playhtml hashes the HTML -->
<img can-move src="/hat.png" alt="" />

For multiple same-template elements (e.g. a row of magnets all using the same markup), add selector-id with a shared CSS selector instead of hand-writing unique ids. playhtml assigns state slots by position — the N-th matching element gets the N-th slot.

<div class="magnet" selector-id=".magnet" can-move>🍎</div>
<div class="magnet" selector-id=".magnet" can-move>🥐</div>
<div class="magnet" selector-id=".magnet" can-move>☕</div>

See Dynamic elements for full details.

You can place multiple capability attributes on one element. Each capability maintains its own independent data slot, keyed by [capabilityName]-[elementId]. They do not interfere with each other’s state.

Transform conflict: can-move, can-spin, and can-grow all write element.style.transform. Combining two or more on the same element means each overwrites the other’s transform on every update. Use can-play if you need a combined transform.

can-move, can-spin, can-toggle, and can-grow reset on shift-click for everyone in the room. Custom can-play elements can opt in with resetShortcut: "shiftKey".