- ClayJS
- How ClayJS works
- What gets saved
What gets saved
What a save writes, which state persists, and the attributes that keep things out of the file.
What a save writes#
A save writes the current DOM with these changes:
- Subtrees marked
no-save,no-snapshot, oreditor-uiare removed. freezeelements write the inner HTML they had when they first appeared (at load or on insertion). Their live contents are a per-tab scratchpad.- Mode attributes go inert (Mode attributes: one file for editors and viewers). Root attributes
editmode,pageowner,savestatus,savetoken, anddocumentetagare removed. persistcontrols write their live values (persist: form controls).- Runtime residue from ClayJS editors (richclay attributes, toolbars) and browser extensions (password managers, Grammarly, Dark Reader) is stripped.
- Your hooks run (Hooks).
Everything else on the page is written as it is, including changes builders forget:
- Classes and attributes toggled for UI:
.selected,aria-expanded,<details open>,hidden. Mark themephemeralinstead (Ephemeral state: per-tab state on saved elements).<dialog open>is the exception: it is never saved. - Inline styles from libraries and helpers: Sortable’s
draggable="false"andstyle="",el.hide(),All(...).css(), andbody.style.overflow = "hidden"from an open modal or crop dialog. - Any element a script inserts outside an excluded region.
State outside the light DOM and attributes is never saved. This includes JavaScript objects, localStorage, canvas pixels, scroll position, focus, and selection. It also excludes shadow DOM content (only the host element’s light DOM and attributes are saved), iframe content, the live value of a control without persist, and password, hidden, and file input values even with persist.
Regions: the clay attribute#
Region tokens on the clay attribute control how ClayJS saves, syncs, watches, and undoes an element and its descendants. Pass space-separated tokens in clay="token token" or use bare attributes like <div no-save>. Set tokens on creation or with clay.region.addRegionToken(el, "editor-ui").
| Token | Saved file | Sent to peers | Starts autosave | Counts as unsaved | In undo | Use for |
|---|---|---|---|---|---|---|
| (none) | current | yes | yes | yes | yes | the record |
editor-ui | removed | no | no | no | no | UI a script fills or changes at runtime, and tab-only derived values (Chrome) |
no-save | removed | carried in the bytes, never applied | no | no | yes | rarely; prefer editor-ui |
no-snapshot | removed | no (each tab keeps its own) | no | no | yes | part of editor-ui |
freeze | element current, contents as when it appeared | element yes, contents never | no | no | yes | a scratchpad: per-tab generated content in a saved element (Derived values) |
no-dirty | current | yes, remote wins | no | no | yes | derived values the saved file must show (Derived values) |
no-trigger-autosave | current | yes | no | yes | yes | a heavy editor whose saves you batch yourself (Manual-save pages and batching) |
no-watch | current | yes, remote wins | no | no | no | rarely: high-frequency content that is disposable |
no-undo | current | yes | yes | yes | no | content with its own undo (richclay sets it at runtime) |
no-data | current | yes | yes | yes | yes | content the data API and extractData must skip |
live | the store’s current value, at checkpoints | never through the merge; the live plugin carries it (Live data: clay="live") | no | no | no | a clay="live" JSON tag |
When chrome needs a region, use editor-ui, not no-save. editor-ui bundles no-data no-save no-snapshot no-watch no-undo. By itself, no-save still records undo steps and stays in the snapshot live sync sends to peers. Peers never apply it, but its text travels to every edit-mode tab (viewers receive the saved file without it). editor-ui replaces clay="no-save no-watch" from older docs.
Legacy spellings still work: save-remove is no-save no-undo, save-ignore is no-dirty no-undo, save-freeze is freeze no-undo, mutations-ignore is no-watch, snapshot-remove is no-snapshot.
Traps in this table:
- An edit inside
no-watchorno-dirtyis invisible toclay.save()and the close warning.save()answers “No changes to save” and closing the tab loses it silently. It is saved only when another change triggers a save, or byclay.save.force(). Never put typed work there. freezeis a scratchpad. The element belongs to the document: its attributes save, and it syncs, moves, and merges like any element. Its contents belong to this tab: a runtime change inside is never saved, not even byclay.save.force(), and never reaches a peer. Every save restores the contents the element had when it first appeared.- A scratchpad from a peer arrives empty. When a peer adds a record that contains a frozen element, it is inserted without contents. Fill scratchpads from code that runs on every change (a sap
effector your render function), never once at boot. - Fill a scratchpad after
clay.ready, and insert new ones empty. The contents are captured when the element first appears. Content written before boot, or present in a cloned element when inserted, becomes its saved contents. Empty the scratchpads in a copied record before inserting it. - Create
editor-uiroots at runtime from an unmarked<template>on every load. Aneditor-uiroot disappears from the saved file, so it cannot be authored in the file. Do not mark the blueprint inside the<template>aseditor-ui, or the first save empties the template and leaves no chrome to clone on the next load. Leave the blueprint unmarked and setclay="editor-ui"on each clone as you insert it.
persist: form controls#
Put persist on every <input>, <textarea>, and <select> whose value belongs to the record. A control’s typed value is a property, not an attribute, and serialization only reads attributes. Without persist, a typed value is lost on save and the file keeps the authored value.
<input type="checkbox" persist viewmode:disabled disabled checked>
<input type="number" step="0.01" persist value="312.40">
<textarea persist></textarea>
<select persist><option>todo</option><option selected>doing</option></select>
- On
input(text-like fields) andchange(checkbox, radio, select), ClayJS mirrors the live value intovalue,checked, orselected. A textarea’s value goes into adata-valueattribute while typing and becomes its text content in the save. - A snapshot also copies every
persistcontrol’s live state, so a value set from script with no event is saved too. - Password, hidden, and file inputs are never persisted. An authored
valueon them stays in the file. persistgoes on the control itself, never on a wrapper.- Changing a
persistcontrol’s value counts as an edit and starts autosave. - Do not persist a filter or search box. Those are tab-local: put them in chrome (Per-tab state).
After undo, redo, or a merge, ClayJS puts each control’s display back in step with its attributes. A control the user has touched shows its live property, not its attribute. A merge and the undo plugin copy checked, value, and selected back onto the property (undo does this for persist controls outside no-undo regions). A merge leaves the focused text field alone, while undo restores it too.
Where to keep data that is not visible text#
In order of preference:
-
Attributes on the element they describe.
<li class="card" data-id="c-7f3a" data-due="2026-11-02" data-points="3">. The default. Saved, merged per attribute, readable by agents, and moves with the element. -
Hidden elements inside the record.
<ul hidden class="archive">, or the card detail inside the card (Detail views: the record is the modal). Same properties, good for archived items and detail fields. -
A JSON script with
merge. For data that markup expresses badly (formula grids, chart series, settings objects):<script type="application/json" id="model" merge="store">{"series":[{"id":"s1","points":[3,5,8]}]}</script>function writeModel(model) { document.getElementById("model").textContent = JSON.stringify(model).replace(/</g, "\\u003c"); }Always escape
<so a value containing</script>cannot close the element early and corrupt the file on reload. Re-parse on everyclay:sync-applied, because a merge changes the text, not your parsed object. When two writers change the same key, the incoming value wins and the lost value shows in the conflict notice (Regions and JSON under sync). -
A live JSON script (
clay="live") for state that changes faster than a save: game boards, scores, cursors (Live data:clay="live"). -
<template>content: never for mutable data. It is saved, but edits there never trigger autosave.
Element identity: data-id and id#
Live sync and undo pair elements across versions by data-id, then id, used only when the value is unique on its side and the tag names match. Without an id, elements pair by content, which works for distinct text and fails for identical new items.
-
Give every repeated item a unique
data-idwhen it is created. With ids, two people adding an item at the same place always keep both items. Without ids, items with distinct text usually both land, items with similar text (“New card”, “New task”) drop one into the conflict ledger, and items with identical or blank text drop one with no ledger record. -
Assign fresh ids when cloning. A duplicated
data-idcounts as no id at all. Re-id the clone and every id-carrying descendant, as the writer’s duplicate command does:const copy = block.cloneNode(true); copy.querySelectorAll('[clay~="editor-ui"]').forEach((el) => el.remove()); copy.querySelectorAll("[data-richclay-active]").forEach((el) => clay.RichClay.stripElement(el)); copy.dataset.id = newId("b"); copy.querySelectorAll("li[data-id]").forEach((li) => { li.dataset.id = newId("t"); }); -
Leave
data-idoff templates. The command that clones a template assigns one. For a plain “add an item” button,<button add-from="#item-tpl" add-to="#list">handles the clone, the id, and the focus with no script. -
Never stamp ids on open. Opening an old file must not rewrite it. Add an id when an item is created or first changed.
-
Make ids without
crypto.randomUUIDunless the file is only ever served over https or localhost, because it is missing in insecure contexts. The examples useprefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6). -
Use ids to find records in commands:
board.querySelector(`.card[data-id="${CSS.escape(id)}"]`). Never find a record by position.
Assets#
ClayJS saves an image through its src. Upload the file and write the returned URL (upload and quickcrop). Every host returns a host path such as /_/uploads/assets-<name>/photo-ab12.png, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files. Where the host cannot store files, embed a data: URL. Data URLs work anywhere, but every save carries the bytes, so keep embedded images small.
Ephemeral state: per-tab state on saved elements#
Use ephemeral for per-tab state on a saved element, such as a selected card, a row being dragged, or an open section. Toggling a normal class or attribute is an edit like any other: it marks the page unsaved, starts an autosave, adds an undo step, reaches every peer, and lands in the file. Mark it ephemeral instead:
<li class="card ephemeral-selected:ring-2" data-id="c-7f3a">…</li>
<details ephemeral:open><summary>Notes</summary>…</details>
clay.ephemeral.toggle(card, "selected"); // add, remove, toggle(el, word, force), has
ephemeral="selected dragging"holds words a script sets for this tab. Style it with[ephemeral~="selected"], or with the Tailwind variantephemeral-selected:(@plugin "@panphora/clayjs/tailwind";; Hyperclay’s on-save Tailwind has it built in).ephemeral:NAMEmakes the element’s ownNAMEattribute tab-only:<details ephemeral:open>,<button ephemeral:aria-expanded>. The marker is saved; the attribute it names is removed from the saved file. Tailwind’s ownopen:andaria-expanded:variants style these.openon<dialog>is tab-only with no marker:showModal()sets it, and a dialog saved open is always a bug.- Ephemeral state is never saved, sent to peers, undone, or counted as a change. An incoming merge from a peer or from the file on disk never writes or removes it on an element this tab already has. The router’s
route-activegets the same treatment. - Identity (
class,id,data-id), region attributes (clay,no-save,freeze, and the rest), and form state (value,checked,selected, whichpersistowns) can never be made ephemeral. - Because ephemeral changes are not changes,
onmutationandonglobalmutationhandlers do not fire for them. Update anything derived from a selection in the same function that changes it.
Use ephemeral for state on one element. State that comes from the URL hash and spans many records (the open card, a filter) is still painted with a runtime stylesheet (Per-tab state).