Malleable HTML files
The tools you need to make a static HTML file behave like a Google Document: editable, auto-save, live sync, etc.
My notes
Click the heading and type. Select text to format it. Then save.
<!DOCTYPE html>
<html>
<body>
<h1 editable>My notes</h1>
<button onclick="clay.save()">Save</button>
<script src="https://clayjs.com/clay.js"></script>
</body>
</html>
That's a complete app. Type into the heading, hit Save, refresh: your words are still there. The file saved itself.
A malleable HTML file is an HTML file that can save itself. There is no database and no build step: the page you're looking at is the data. When you save, clayjs snapshots the page's HTML, cleans it up, and POSTs it back to the server it came from, which writes it to disk. Next load, the file opens exactly the way you left it.
“I love simple solutions to problems that modern webdev has made unnecessarily complicated.”
kami · developer
You changed the page, and a refresh throws it away.
Add the script tag, then call clay.save() whenever you want to persist the page. Wire it to a button, a keystroke, or your own logic:
<script src="https://clayjs.com/clay.js"></script>
<button onclick="clay.save()">Save</button>
<script type="module">
// or from your own code, with the server's answer:
await clay.ready;
const { msg, msgType } = await clay.save();
</script>
What actually happens
clayjs POSTs the file's own HTML to /_/save on the server it was loaded from, and gets back a small JSON answer: { "msg": "Saved" }. That's the whole protocol. Anything that implements that one endpoint can host malleable files:
| Server | What it is |
|---|---|
| hyperclay.com | Hosted platform: accounts, versions, sharing |
| Hyperclay Local | Desktop app that serves a folder on your machine |
| htmlclay | Desktop app: double-click a .htmlclay file and it just saves |
| Your own | About 12 lines of Express. Endpoint spec. |
You hit save. Did it work?
clayjs never draws its own interface. Instead it keeps a savestatus attribute on <html> up to date (saving, saved, error, offline), and you decide what that looks like. Paste this CSS and you have a status chip, no JavaScript needed:
html[savestatus] body::after {
position: fixed; right: 16px; bottom: 16px;
padding: 4px 12px; border-radius: 99px; font: 13px system-ui;
}
html[savestatus="saving"] body::after { content: "Saving…"; background: #eee; }
html[savestatus="saved"] body::after { content: "Saved"; background: #dcefe0; color: #2f5d3a; }
html[savestatus="error"] body::after { content: "Couldn't save"; background: #f6e3de; color: #a04f38; }
Try both outcomes:
The chip is yours: restyle it, move it, delete it.
Don't want to touch CSS? Check “a built-in save indicator” in the configurator and clayjs ships a small ready-made one.
Roll your ownevents + promise
Every save fires events on document and returns a promise, so any UI you already have can listen in:
document.addEventListener("clay:save-saved", () => myToast("Saved"));
document.addEventListener("clay:save-error", e => myToast(e.detail.msg));
// or handle it inline:
const { msg, msgType } = await clay.save();
Events: clay:save-saving, clay:save-saved, clay:save-error, clay:save-offline. Each carries detail.msg.
When it failserror + offline
savestatus="error" means the server answered with a problem; the message is in the event's detail.msg. savestatus="offline" means the request couldn't get out at all. And if someone closes the tab with unsaved changes, clayjs warns them first. That safety net is built in.
A page you can't type into isn't a document.
Put editable on anything: it becomes rich text you can type into, with bold, italics, lists, and links. Form controls need one attribute, persist, which writes their current value into the HTML itself so it survives the save:
<p editable>Meeting notes for Tuesday…</p>
<input name="title" persist>
<textarea name="draft" persist></textarea>
Meeting notes for Tuesday…
Edit both, save, then reload: everything survives. Reload without saving and your changes are gone, which is the point.
Saved files have a lifecycle
Three things come up in almost every malleable file, usually in this order. Open the one you're facing.
I shouldn't have to press Save.autosave
Put autosave on the <html> element and clayjs saves for you: edits settle for a moment, then a save fires. It's debounced and throttled, so rapid typing never spams the server.
<html autosave>
Type here and just stop. It saves on its own.
It's open in two places and the copies fight.live sync
Check “sync edits across open copies” in the configurator. Edits then flow into every open copy of the file, morphing the page in place: no reloads, and nobody loses their cursor. It's off by default on purpose: it holds a connection open, and you don't need that until you have this problem.
Type in either copy…
Type in either copy…
Visitors can see my edit controls.edit mode
Your first malleable file can keep its controls exposed; that's fine. When you share it, mark the owner-only parts and clayjs strips or disables them for everyone else. The server decides who the owner is.
<h1 editmode:contenteditable>Only the owner can edit this</h1>
<button clay="no-save" onclick="clay.save()">Save</button>
Sourdough, third attempt
More water, less patience.
Visitors won't see these buttons or the dashed underline.
Control what gets saved
Some parts of the page shouldn't be part of the document.
A search box, a connection badge, a ticking clock: they live on the page but they aren't content. One attribute, clay, tells clayjs how any element takes part in saving:
<nav clay="no-save no-watch"> …toolbar, never written to disk… </nav>
<span clay="freeze">12:04:33</span> <!-- saved as authored, ticking ignored -->
<div clay="no-watch"> …a third-party widget that churns the DOM… </div>
Live or computed text (clocks) → clay="freeze"
Third-party embeds → clay="no-watch"
Heavy editors, batch the saves → clay="no-trigger-autosave"
Six keywords total: no-save, no-snapshot, no-trigger-autosave, no-watch, no-undo, freeze. Full reference on the advanced page.
What does your file need?
Check what applies. The script tag updates as you go; plugins load only in the browser, only when checked.
CMS setup: add one more tag naming the parts of your page that count as content. The sidebar builds itself from it:
<script data-rules-name="cms" type="application/json">
{
"title": ".page-title",
"intro": "p.intro",
"avatar": "img.avatar@src",
"tags": "ul.tags li[]"
}
</script>
Each entry is a field: a name, then the selector it edits. Open the sidebar with ?cms=true on the URL. The same tag can power the JSON data API too.
Need more? Reactive templates, JSON data in and out, toasts and dialogs, DOM helpers: those are separate libraries with their own script tags, on Plugins and Advanced.
Your file is yours
It runs on hyperclay.com, on your own machine with Hyperclay Local or htmlclay, or against about 12 lines of your own server code. Download it anytime; it's just HTML.
The fastest way to try a malleable file with zero setup is a free account on hyperclay.com.