- ClayJS
- Build
- Patterns
Patterns
Core DOM-first patterns for building malleable documents with ClayJS: locality of concern, inline code, template cloning, delegation, and DOM state.
Malleable documents keep structure, state, and behavior together in the DOM. These seven patterns cover the building blocks; see App architecture for how they fit together in full documents.
Locality of concern#
Keep markup, styling, and behavior together in the element they belong to rather than splitting them across separate files. When structure and behavior sit in one place, you can read the element and see everything it does.
<div class="counter">
<h3>Counter: <span>0</span></h3>
<button type="button" editmode:onclick inert-onclick="this.previousElementSibling.querySelector('span').textContent++" show-when:editmode="true">
Increment
</button>
</div>
Inline code#
Inline event attributes (onclick, oninput, onchange) keep small behaviors local to the element where the interaction happens. Use editmode:onclick when a handler should run only in edit mode and stay inert for visitors:
<button type="button" onclick="this.textContent = 'Clicked'">Click me</button>
<input oninput="this.nextElementSibling.textContent = this.value">
<select onchange="document.body.dataset.theme = this.value">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
When you want concise inline queries across multiple elements, load the all.js satellite (https://clayjs.com/v1/all.js) and use All:
<button type="button" onclick="All.panel.classList.toggle('expanded')">Toggle panels</button>
<button type="button" editmode:onclick inert-onclick="All.item.filter(el => el.dataset.status === 'draft').forEach(el => el.remove())">
Clear drafts
</button>
Template clone#
Build dynamic lists by cloning a <template> element rather than assembling HTML strings in JavaScript. Keep the <template> outside the list and write its controls in their active form (see Edit mode and view mode).
For a button that adds an item in edit mode, use add-from and add-to. A click clones the <template> named by add-from, assigns the clone a fresh data-id, appends it to the container named by add-to, and focuses its first editable field:
<ul id="tasks">
<li class="task" data-id="t-1">
<input type="checkbox" persist viewmode:disabled disabled>
<span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Write the brief</span>
</li>
</ul>
<template id="task-tpl">
<li class="task">
<input type="checkbox" persist viewmode:disabled>
<span class="title" editmode:contenteditable contenteditable="plaintext-only"></span>
</li>
</template>
<button type="button" add-from="#task-tpl" add-to="#tasks" show-when:editmode="true">+ Add task</button>
When adding an item requires extra logic such as logging activity or wrapping the change in an undo commit, clone template.content.firstElementChild in a command function as shown in App architecture.
Event delegation#
Because records are added, removed, and merged dynamically, attach one delegated listener on document inside clay.start() rather than binding listeners to individual elements. A delegated listener survives live sync merges and undo replays that replace or re-insert nodes:
<script>
clay.start(() => {
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button || !clay.isEditMode) return;
if (button.dataset.action === "complete") {
button.closest(".task").dataset.status = "done";
}
if (button.dataset.action === "delete") {
button.closest(".task").remove();
}
});
});
</script>
See App architecture for structuring delegated commands with undo commits and soft deletes (clay.trash).
Progressive enhancement#
Start with plain HTML and layer capabilities one attribute at a time:
<!-- 1. Static HTML -->
<h1>Reading list</h1>
<!-- 2. Editable in the browser -->
<h1 contenteditable="plaintext-only">Reading list</h1>
<!-- 3. Editable in edit mode, saved inert for visitors -->
<h1 editmode:contenteditable inert-contenteditable="plaintext-only">Reading list</h1>
Each attribute adds a single behavior while keeping the saved markup clean for visitors.
State machines in the DOM#
Store durable workflow state as attributes on DOM elements and control visibility declaratively with the clay-options.js satellite (show-when:, hide-when:, option:, option-not:):
<div id="board" view="cards" onbeforesnapshot="this.setAttribute('view', 'cards')">
<div show-when:view="cards">Card view</div>
<div show-when:view="table">Table view</div>
</div>
For computed values derived from records, use clay.effect() (or sap) to update a clay="editor-ui" or clay="no-dirty" element whenever the document changes:
<div id="calculator">
<input type="number" name="price" value="100" persist>
<input type="number" name="quantity" value="2" persist>
<div>Total: $<span id="total" clay="no-dirty">200.00</span></div>
</div>
<script>
clay.start(() => {
const calc = document.getElementById("calculator");
const total = document.getElementById("total");
clay.effect(() => {
const price = parseFloat(calc.querySelector('[name="price"]').value) || 0;
const quantity = parseFloat(calc.querySelector('[name="quantity"]').value) || 0;
const next = (price * quantity).toFixed(2);
if (total.textContent !== next) total.textContent = next;
});
});
</script>
See App architecture for choosing between saved record attributes, per-tab state (URL hash, clay.storage, ephemeral), and derived values.
Single-file philosophy#
In a malleable document, everything lives in one HTML file:
- Markup: the structure
- Styles: the presentation
- Scripts: the behavior
- Record data: the durable state stored in elements, attributes, text, and
persistcontrols - Chrome: the editing interface, hidden from visitors with
show-when:editmode="true"or mounted at runtime insideclay="editor-ui"
The HTML file is at once the source code, the deployed document, the editing environment, and the database. For how to separate records, chrome, and derived values so saves and peer merges stay clean, read App architecture.