clay.js
  1. ClayJS
  2. How ClayJS works
  3. 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, or editor-ui are removed.
  • freeze elements 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, and documentetag are removed.
  • persist controls 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 them ephemeral instead (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" and style="", el.hide(), All(...).css(), and body.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").

TokenSaved fileSent to peersStarts autosaveCounts as unsavedIn undoUse for
(none)currentyesyesyesyesthe record
editor-uiremovednonononoUI a script fills or changes at runtime, and tab-only derived values (Chrome)
no-saveremovedcarried in the bytes, never appliednonoyesrarely; prefer editor-ui
no-snapshotremovedno (each tab keeps its own)nonoyespart of editor-ui
freezeelement current, contents as when it appearedelement yes, contents nevernonoyesa scratchpad: per-tab generated content in a saved element (Derived values)
no-dirtycurrentyes, remote winsnonoyesderived values the saved file must show (Derived values)
no-trigger-autosavecurrentyesnoyesyesa heavy editor whose saves you batch yourself (Manual-save pages and batching)
no-watchcurrentyes, remote winsnononorarely: high-frequency content that is disposable
no-undocurrentyesyesyesnocontent with its own undo (richclay sets it at runtime)
no-datacurrentyesyesyesyescontent the data API and extractData must skip
livethe store’s current value, at checkpointsnever through the merge; the live plugin carries it (Live data: clay="live")nononoa 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-watch or no-dirty is invisible to clay.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 by clay.save.force(). Never put typed work there.
  • freeze is 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 by clay.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 effect or 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-ui roots at runtime from an unmarked <template> on every load. An editor-ui root disappears from the saved file, so it cannot be authored in the file. Do not mark the blueprint inside the <template> as editor-ui, or the first save empties the template and leaves no chrome to clone on the next load. Leave the blueprint unmarked and set clay="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.

HTML
<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) and change (checkbox, radio, select), ClayJS mirrors the live value into value, checked, or selected. A textarea’s value goes into a data-value attribute while typing and becomes its text content in the save.
  • A snapshot also copies every persist control’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 value on them stays in the file.
  • persist goes on the control itself, never on a wrapper.
  • Changing a persist control’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:

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

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

  3. A JSON script with merge. For data that markup expresses badly (formula grids, chart series, settings objects):

    HTML
    <script type="application/json" id="model" merge="store">{"series":[{"id":"s1","points":[3,5,8]}]}</script>
    
    JavaScript
    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 every clay: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).

  4. A live JSON script (clay="live") for state that changes faster than a save: game boards, scores, cursors (Live data: clay="live").

  5. <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-id when 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-id counts as no id at all. Re-id the clone and every id-carrying descendant, as the writer’s duplicate command does:

    JavaScript
    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-id off 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.randomUUID unless the file is only ever served over https or localhost, because it is missing in insecure contexts. The examples use prefix + "-" + 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:

HTML
<li class="card ephemeral-selected:ring-2" data-id="c-7f3a">…</li>
<details ephemeral:open><summary>Notes</summary>…</details>
JavaScript
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 variant ephemeral-selected: (@plugin "@panphora/clayjs/tailwind";; Hyperclay’s on-save Tailwind has it built in).
  • ephemeral:NAME makes the element’s own NAME attribute 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 own open: and aria-expanded: variants style these.
  • open on <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-active gets the same treatment.
  • Identity (class, id, data-id), region attributes (clay, no-save, freeze, and the rest), and form state (value, checked, selected, which persist owns) can never be made ephemeral.
  • Because ephemeral changes are not changes, onmutation and onglobalmutation handlers 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).

Part of the ClayJS complete guideDownload the whole guide as Markdown