Capabilities
Every capability in playhtml is a single HTML attribute. Slap it on an element, give the element an id, and the library handles sync, persistence, and event wiring. This page catalogs the eight built-ins plus can-play, in order from most-commonly used to most-specialized.
Every section below has the same shape:
- A short description of what the capability does and the kind of state it owns.
- A live demo you can interact with — open the page in a second tab and watch both update.
- The vanilla HTML form, and the React form. Pick the framework you’re shipping in; the reader’s choice syncs across every snippet on the page.
This page also runs a few can-play demos directly in its margins — a doodle strip below, a guestbook further down, a prize wheel at the bottom, and a shared tally on every code block you copy from. They’re discussed properly in the can-play section.
Draw a tiny 64×64 face. It joins the strip for everyone reading this page. Capped at 20.
can-move
Section titled “can-move”What it does. Drag anywhere. Position persists and syncs.
Use it for: drag-and-drop affordances — fridge magnets, game pieces, arrangeable stickers, a puzzle.
<div id="arena" style="position: relative; height: 320px;">
<img can-move can-move-bounds="arena" id="hat" src="/yankees-hat.png" alt="" />
<img can-move can-move-bounds="arena" id="cat" src="/long-cat.png" alt="" />
</div> import { CanMoveElement } from "@playhtml/react";
<div id="arena" style={{ position: "relative", height: 320 }}>
<CanMoveElement bounds="arena">
<img id="hat" src="/yankees-hat.png" alt="" />
</CanMoveElement>
<CanMoveElement bounds="arena">
<img id="cat" src="/long-cat.png" alt="" />
</CanMoveElement>
</div> Constraining the drag area
Section titled “Constraining the drag area”Use can-move-bounds (vanilla) or the bounds prop (React) to keep the element inside a container. The value is an element id (with or without the leading #) or any valid CSS selector.
<div id="fridge" style="position: relative; height: 400px;">
<div can-move can-move-bounds="fridge" id="magnet-a">🍎</div>
<div can-move can-move-bounds="#fridge" id="magnet-b">🥐</div>
</div> import { CanMoveElement } from "@playhtml/react";
<div id="fridge" style={{ position: "relative", height: 400 }}>
<CanMoveElement bounds="fridge">
<div id="magnet-a">🍎</div>
</CanMoveElement>
<CanMoveElement bounds="#fridge">
<div id="magnet-b">🥐</div>
</CanMoveElement>
</div> The cursor can go past the container edge — only the element’s position is clamped. By default, the keep-visible slice is max(25% of element size, 60px) on every edge, so readers always have something to grab. Two knobs let you tune that:
can-move-bounds-min-visible/boundsMinVisible— fraction(0–1)of the element to keep inside.1pins fully inside (strict clamp);0drops the fraction constraint entirely.can-move-bounds-min-visible-px/boundsMinVisiblePx— absolute pixel floor. Useful when an image has transparent padding around its paint; a pure fraction of the layout bbox can let the visible pixels clip into invisible border.
The effective slice on each axis is max(fraction × size, pxFloor). Set both to 0 to opt fully out of the keep-visible guarantee and let the element slip entirely out of view.
<!-- Keep 50% of the magnet inside the fridge, ignoring the px floor -->
<div can-move can-move-bounds="fridge"
can-move-bounds-min-visible="0.5"
can-move-bounds-min-visible-px="0"
id="magnet">
🧲
</div>
<!-- Small magnet: fraction of 0.25 × 40px = 10px is too small to grab.
The 60px default floor keeps the whole magnet visible regardless. -->
<div can-move can-move-bounds="fridge" id="tiny-magnet" style="width: 40px;">
🌶️
</div> import { CanMoveElement } from "@playhtml/react";
{/* Keep 50% of the magnet inside the fridge, ignoring the px floor */}
<CanMoveElement bounds="fridge" boundsMinVisible={0.5} boundsMinVisiblePx={0}>
<div id="magnet">🧲</div>
</CanMoveElement>
{/* Small magnet: the 60px default floor keeps the whole magnet visible. */}
<CanMoveElement bounds="fridge">
<div id="tiny-magnet" style={{ width: 40 }}>🌶️</div>
</CanMoveElement> can-toggle
Section titled “can-toggle”What it does. Click to flip an on/off boolean. Persists and syncs.
Use it for: shared switches, lamps, “is-this-thing-open” signs, per-element read/unread state. The playhtml homepage uses this on the wordmark letters and the hanging lamp — click either to flip it for everyone.
<button id="my-switch" class="switch" can-toggle>off</button>
<style>
.switch.clicked { background: #6cd97e; }
.switch.clicked::after { content: "on"; }
</style> import { CanToggleElement } from "@playhtml/react";
<CanToggleElement>
{({ data }) => {
const on = typeof data === "object" ? data.on : !!data;
return (
<button
id="my-switch"
type="button"
className={on ? "is-on" : "is-off"}
aria-pressed={on}
>
{on ? "on" : "off"}
</button>
);
}}
</CanToggleElement>CanToggleElement already wires up the click handler — don’t add your own onClick, or you’ll toggle twice per click. Render declaratively from data.
can-grow
Section titled “can-grow”What it does. Click to scale up. Alt-click (or equivalent modifier) to scale down. Persists and syncs.
Use it for: zoomable images, inflating a balloon or sticker, growing a banner over time.
<img can-grow id="balloon" src="/water-balloon.png" alt="" /> import { CanPlayElement } from "@playhtml/react";
import { TagType } from "@playhtml/common";
<CanPlayElement tagInfo={[TagType.CanGrow]} id="balloon" defaultData={{ scale: 1 }}>
{() => <img src="/water-balloon.png" alt="" />}
</CanPlayElement> can-spin
Section titled “can-spin”What it does. Drag to rotate. Persists and syncs.
Use it for: wheels, dials, gauges, spinnable stickers or album covers, “what’s your answer” wheels.
<img can-spin id="wheel" src="/bike-wheel.webp" alt="" /> import { CanPlayElement } from "@playhtml/react";
import { TagType } from "@playhtml/common";
<CanPlayElement tagInfo={[TagType.CanSpin]} id="wheel" defaultData={{ rotation: 0 }}>
{() => <img src="/bike-wheel.webp" alt="" />}
</CanPlayElement> can-hover
Section titled “can-hover”What it does. Tracks which cursors are currently hovering an element. Presence-based, not persistent — when a user leaves, their hover clears.
Use it for: “who’s looking at this right now” affordances, social read-receipts, zero-latency shared hover effects.
<div can-hover id="hover-pad">hover me</div> import { CanPlayElement } from "@playhtml/react";
import { TagType } from "@playhtml/common";
<CanPlayElement
tagInfo={[TagType.CanHover]}
id="hover-pad"
defaultData={{}}
myDefaultAwareness={"#3b82f6"}
>
{({ awareness }) => (
<div
style={{
background: `linear-gradient(45deg, ${awareness.join(", ")})`,
}}
>
hover me
</div>
)}
</CanPlayElement> can-duplicate
Section titled “can-duplicate”What it does. Click a trigger to clone an existing element into a new one. Every clone is independent shared state.
Use it for: user-generated galleries, seed-from-template UIs, stamps, “leave your mark” patterns.
<img id="bunny-template" src="/pixel-bunny.png" alt="" />
<button can-duplicate="#bunny-template" id="clone-btn">clone a bunny</button>
<script>
// The "reset" button sweeps every spawned rabbit from shared state.
document.getElementById("reset-btn").addEventListener("click", () => {
document.querySelectorAll("[data-duplicate-source='#bunny-template']").forEach((el) => {
playhtml.deleteElementData("can-duplicate", el.id);
el.remove();
});
});
</script>
<button id="reset-btn">reset</button> // React wiring for can-duplicate typically pairs with setupPlayElement
// so freshly-mounted clones register themselves. See "Dynamic elements". can-mirror
Section titled “can-mirror”What it does. A meta-capability: can-mirror auto-syncs every attribute, child-node change, and form-state change on the element. You don’t write defaultData, setData, or an updateElement callback — the library observes the DOM for you.
Use it for: when the state you want to share is the DOM: a shared textarea, a form, a growing list of children.
Vignette A — an emoji-only textarea
Section titled “Vignette A — an emoji-only textarea”A textarea that filters to emoji characters on input. The filtered value mirrors to everyone.
A wide emoji-only textarea. Type anything; only emojis stick. Everyone composes the message together.
<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>
Vignette B — a list you can add children to
Section titled “Vignette B — a list you can add children to”Click ”+” to append a new <li>. The child addition itself mirrors — new readers see the whole list, not just the latest.
A shared guestbook list. Append your signature; the full list mirrors to everyone else.
<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>
PlaygroundSee every can-mirror edge case →Hover, focus, every form input, contenteditable, programmatic attribute changes — one page with a live demo for each. Open in two tabs to watch it sync.
Drop a note about what you’re learning or building. The list is shared, capped at 20, oldest entries fall off.
can-play
Section titled “can-play”What it does. The “build your own capability” escape hatch. You define defaultData, onClick / onDrag / onMount handlers, and an updateElement callback — playhtml syncs the data and fires your handlers.
Use it for: anything the built-ins don’t already cover. Custom counters, guestbooks, chat, games, reactions, per-user state, event-based broadcasts.
This page is itself a can-play showcase. Four examples live in the margins:
- Doodle strip — freehand canvas input encoded as a tiny PNG data URL. The shared array caps at 20 via
spliceinside the setData mutator. - Guestbook — a growing list of short text entries. Same “cap the array” pattern, different payload.
- Prize wheel — one shared piece of data (the spin seed + target) animates identically on every tab. Every visitor sees the wheel land on the same label.
- Tally on every code block — every
<pre>on this page is a vanillacan-playelement. Clickcopyon any snippet and watch a tally tick appear next to the button — readers all over the site share that counter, so popular snippets accumulate marks like a well-loved library book.
That’s four wildly different shapes — short array, blob payload, animation seed, single integer — built on the same primitive. The minimum viable shape is much smaller, though:
<button can-play id="cheer" data-count="0">❤️ <span>0</span></button>
<script>
const el = document.getElementById("cheer");
el.defaultData = { count: 0 };
el.onClick = (_e, { data, setData }) => setData({ count: data.count + 1 });
el.updateElement = ({ element, data }) => {
element.querySelector("span").textContent = data.count;
};
</script> import { withSharedState } from "@playhtml/react";
export const CheerButton = withSharedState(
{ defaultData: { count: 0 }, id: "cheer" },
({ data, setData }) => (
<button onClick={() => setData({ count: data.count + 1 })}>
❤️ {data.count}
</button>
),
); Deeper material on custom capabilities lives in Data essentials and Dynamic elements.
Composed examples
Section titled “Composed examples”These capabilities are the building blocks. The best way to see them in concert is to visit the live experiments room, where readers leave permanent marks in a shared space.
These are the same three experiments the homepage slot machine surfaces — the fastest way to feel playhtml at room-scale.
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.