Re-render with plain HTML or JSX, and the page
keeps focus and selection keeps what users typed moves keyed nodes instead of rebuilding them changes only what differs takes partial change-sets
defuss-morph compares new markup with the live DOM and patches the difference with native DOM calls. A node that exists before and after stays the same node, so focus, the text in an input and its event listeners survive the update. It needs no virtual DOM and no build step, works next to any framework, and runs on the server against happy-dom as well as in the browser.
Built by Aron Homberg (kyr0) (opens in a new tab), open source (MIT (opens in a new tab))
Move, never rebuild
Children are matched by key, then by id, then by tag. A matched node is moved with moveBefore() and patched in place, so its identity, focus and caret survive.
Typed input stays
An input keeps its live value and checked state unless the new markup sets them. A textarea that has focus keeps its content while you type.
Native event behaviour
JSX handlers such as onClick listen on the element itself, so order, stopPropagation() and non-bubbling events behave natively. Removing an element tears its handlers down.
Partial change-sets
With { diff: true } you send only the items that changed, addressed by key or id. Diff mode never removes or moves anything, which suits streamed and AI-generated updates.
Browser and server
HTML is parsed by the DOMParser of the target element's own document, so the same call works in a browser, in happy-dom and across windows. The library entry has no side effects.
One file, no build step
One <script type="module"> tag loads 7.0 kB gzip and exposes the API as window.df$. Bundlers get side-effect-free ESM and CommonJS builds with type declarations.
Morph against innerHTML, on the same markup
Type into the inputs on the stage, tick a box, leave the cursor in a field. Then edit the markup and apply it, or load an example edit first. A MutationObserver counts what each engine did to the DOM. morph() runs the copy of the built library this site serves; innerHTML is the baseline that rebuilds everything.
- Elements kept
- 0
- Created
- 0
- Removed
- 0
- Attribute writes
- 0
- Text writes
- 0
Apply a state to see what changed.
One pass over the children, matching before creating
Both inputs end up as the same structure: an HTML string is parsed into a detached document and read back as plain objects; JSX already is one. The morph then walks the target's children once. Every new child first looks for a node it can take over; only a child without one is created, and whatever is left unclaimed at the end is removed.
- no
- key, id or tag
What survives a morph
Node identity
A matched node is the same object afterwards. References you hold,
WeakMapentries and third-party widgets attached to it stay valid.Focus and selection
A node is moved with
moveBefore(), which keeps a focused input focused with its caret. Browsers without it getinsertBefore(), after which morph puts focus and the selection back.Uncontrolled form state
Markup without
valueorcheckedleaves the live state alone. Markup that declares them, or a VNode withchecked: false, sets it.Event listeners
Listeners you added with
addEventListenerstay on surviving nodes. An HTML string cannot declare handlers, so registered JSX handlers stay too.Comments
HTML comments are matched and patched like text, so
morph(el, el.innerHTML)changes no node.Lifecycle hooks
onMountruns after insertion;onUnmountruns once when the element leaves the document, with the handler of the latest render.
Ordering rules
-
Full state by default
A plain
morph()describes all children: whatever the input does not mention is removed. -
Nested morphs queue
A morph started from inside another morph on the same element or a descendant waits; the latest request runs when the outer morph returns.
-
The latest content wins
A morph issued while a transition runs applies at once and also replaces the content that transition will apply, so it never ends on stale markup.
-
Shadow roots are targets
An element with an open shadow root is morphed inside it. Custom elements (tag names with a hyphen) keep their light DOM as the target, for slotted content.
One function for most pages, primitives for frameworks
Most code needs only morph(). The declarations ship as dist/index.d.ts; the source of truth is src/index.ts.
morph(el, newContent, options?) makes the children of el match newContent: an HTML string, a VNode, an array of VNodes or plain text. It returns undefined and finishes synchronously, unless a transition is requested; then it returns a Promise that resolves when the transition has finished.
| Option | Type | Default | Meaning |
|---|---|---|---|
diff | boolean | false | Apply a partial change-set instead of the full child list (see Diff mode). |
transition.type | 'fade' | 'slide-left' | 'slide-right' | 'shake' | 'none' | 'fade' | fade cross-fades a snapshot of the old content; the others animate out, morph, then animate in. none keeps the call synchronous. |
transition.duration | number (ms) | 300 | Length of each animation phase. |
transition.easing | string | 'ease-in-out' | Any CSS timing function. |
transition.delay | number (ms) | 0 | Wait before the transition starts. |
transition.target | 'parent' | 'self' | 'parent' | Which element is animated. With 'parent', an element that has no parent is morphed without animation. |
transition.styles | { enter, enterActive, exit, exitActive } | by type | Your own CSS property maps for the four phases (not used by fade). |
import { morph } from 'defuss-morph';const list = document.getElementById('todo');morph(list, `<li key="a">Alpha</li><li key="b">Beta</li>`);// b moves in front of a; neither node is recreatedmorph(list, `<li key="b">Beta</li><li key="a">Alpha, edited</li>`);await morph(list, '<li>Done</li>', { transition: { type: 'fade', duration: 200 } });
import { morph } from 'defuss-morph';// any JSX factory that returns { type, attributes, children } works,// e.g. defuss; this is the same tree written out by handmorph(document.getElementById('app'), { type: 'button', attributes: { key: 'save', onClick: () => save(), disabled: false }, children: ['Save'],});
With { diff: true } the input is a change-set for the direct children of el. Each item names its node by key (preferred) or id. A matched node gets the declared attributes merged in, and its children replaced only if the item declares children. An unmatched item is appended. Nothing else is touched.
| Item | Effect |
|---|---|
| Matches an existing child | Patched in place: declared attributes set, undeclared ones and registered handlers kept. A different tag replaces the node. |
| Matches nothing | Appended at the end. It needs a tag, because it has to be created. |
No key or id, or plain text | Throws an Error: there is no unambiguous node to address. |
| Top-level comment, whitespace | Skipped. Comments inside items are morphed. |
| Child not mentioned | Untouched: identity, order, attributes and listeners stay. Use a full morph() to delete. |
// rows a, b and c exist; send only what changedmorph(rows, `<tr key="b" class="done"><td>b finished</td></tr>`, { diff: true });// a VNode item without a tag borrows it from the node it addressesmorph(form, [{ attributes: { id: 'terms', checked: false } }], { diff: true });
The building blocks morph() is made of are exported for framework integration; defuss-query is built on them. Every globals argument is optional and derived from the element's own document.
| Export | What it does |
|---|---|
updateDomWithVdom(el, vnodes, globals?, mode?) | The guarded morph entry point for VNodes. mode is 'replace' (default) or 'diff'. Nested calls queue as described above. |
replaceDomWithVdom(el, vnodes, globals?) | Removes every child, with the same handler teardown as a morph, and renders the input from scratch. |
htmlStringToVNodes(html, DOMParser) | Parses markup into VNodes. Fragments get an explicit <body> so a leading comment is kept; full documents parse unchanged. |
domNodeToVNode(node) | Reads a DOM node back as a VNode, marked as coming from the DOM so its handlers are preserved. |
getRenderer(document) | Creates DOM from VNodes: elements, SVG namespaces, text and comment nodes, attributes, refs and lifecycle hooks. |
registerDelegatedEvent(el, type, handler, options?) | Adds a handler as JSX does: one slot per event and phase, or with { multi: true } one native listener per handler. |
removeDelegatedEvent, clearDelegatedEventsDeep | Remove one type, or every handler of a subtree. |
onClearDelegatedEvents(hook) | Runs hook(element) whenever morph removes or replaces an element, so a library with its own listeners can release them. Returns an unregister function. |
performTransition(el, update, config) | The transition runner behind the transition option. |
Three rules follow from treating markup that does not mention something as "leave it alone".
HTML cannot uncheck a box
Markup without checked means "not controlled", so a box the user ticked stays ticked. The same holds for an input's value. To set the state explicitly, send a VNode: in diff mode, morph(el, [{ attributes: { id: 'terms', checked: false } }], { diff: true }).
Inline on-attributes are not removed
Attributes whose name starts with on, such as onclick="…", are never removed by a later morph. Attach behaviour with addEventListener or JSX handlers instead of inline attributes.
Diff mode cannot delete
A change-set only patches and appends. Removing a node, or reordering, needs a full morph() of the parent.
Small, and measured
The sizes below are written into this page by make build from the files it just built (dist/stats.json), and the build fails while this page shows other numbers.
all.min.js, the one-file CDN build (19.2 kB raw)make build
index.mjs 39.8 kB 9.8 kB gzip
index.cjs 40.9 kB 10.0 kB gzip
all.js 41.4 kB 10.1 kB gzip
all.min.js 19.2 kB 7.0 kB gzip
Add it to your page or your app
Load one file from the CDN, install the package, or copy the built file into your assets. Then morph, or hand one prompt to your coding agent.
From the CDN
The bundle registers the API as window.df$, or you import it as a module. An existing df$, for example from defuss-query, is extended rather than replaced.
<script type="module" src="https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js"></script><script type="module"> df$.morph(document.getElementById('app'), '<p>Hello</p>');</script>
<script type="module"> import { morph } from 'https://cdn.jsdelivr.net/npm/defuss-morph@latest/dist/all.min.js'; morph(document.getElementById('app'), '<p>Hello</p>');</script>
With a package manager
Pins the version, works offline and lets your bundler tree-shake. The package entries register no global.
bun add defuss-morph
npm install defuss-morph
pnpm add defuss-morph
Vibe coding
Paste this prompt into your coding agent. It links the repository, so the agent works from the current README.
Use defuss-morph for DOM updates in this app: https://github.com/kyr0/defuss-morph
Read its README.md first. Install it with the project's package manager, or load dist/all.min.js from jsDelivr where there is no bundler.
Re-render by calling morph(container, html) with the complete next state instead of assigning innerHTML. Give list items a stable key attribute so they move instead of being rebuilt.
Use { diff: true } only for change-sets addressed by key or id, and VNodes where state must be set explicitly, such as checked: false.
send
Give your agent the defuss-vae skills too: it then plans, tests and reviews against a verifier instead of declaring itself done. In Claude Code, the plugin adds the commit and Stop-hook gate. Needs python3 3.9 or newer, git and make.
npx skills add kyr0/defuss-vae --skill '*'
claude plugin marketplace add kyr0/defuss-vaeclaude plugin install defuss-vae@defuss-vae

Stressed with inconsistent results and tiring reviews?
Build more consistent agents with defuss-vae (opens in a new tab), Aron Homberg's Verified Agentic Engineering (opens in a new tab) method. Aron is a freelance AI researcher, O'Reilly author (2011), conference speaker, and longtime mentor to software engineering teams.
...if you're looking for a remote training or mentoring session for you or your engineering team.
Morph HTML like JSX, without using innerHTML
Keyed moves, preserved form state and native event behaviour, focus state and input handling - all in one file without dependencies.
MIT licensed · 0.2.0 · zero runtime dependencies