v0.2.0Zero dependencies · 7.0 kB gzip · any framework or none

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.

Live demo

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.

Engine
Stage
Elements kept
0
Created
0
Removed
0
Attribute writes
0
Text writes
0

Apply a state to see what changed.

How it works

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.

morph(el, html or JSX)
Strings onlyParseDOMParser of el.ownerDocument
ChildrenNormalizeflatten fragments, join text
Match found?
Createinsert, run onMount
Same nodeMove into placemoveBefore keeps identity
In placePatch attributesform state and handlers kept
Recurse into children
LeftoversRemove unclaimedhandlers torn down first
DOM matches the input
  1. no
  2. key, id or tag

What survives a morph

  • Node identity

    A matched node is the same object afterwards. References you hold, WeakMap entries 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 get insertBefore(), after which morph puts focus and the selection back.

  • Uncontrolled form state

    Markup without value or checked leaves the live state alone. Markup that declares them, or a VNode with checked: false, sets it.

  • Event listeners

    Listeners you added with addEventListener stay 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

    onMount runs after insertion; onUnmount runs once when the element leaves the document, with the handler of the latest render.

Ordering rules

  1. Full state by default

    A plain morph() describes all children: whatever the input does not mention is removed.

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

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

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

API

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.

OptionTypeDefaultMeaning
diffbooleanfalseApply 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.durationnumber (ms)300Length of each animation phase.
transition.easingstring'ease-in-out'Any CSS timing function.
transition.delaynumber (ms)0Wait 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 typeYour 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'],});

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.

7.0 kB gzipall.min.js, the one-file CDN build (19.2 kB raw)
0runtime or peer dependencies
2test environments: every test file runs in real Chromium, and all but the browser-only ones also in happy-domThe test setup
4builds: ESM and CommonJS for bundlers and Node, readable and minified bundles for the CDN
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
gzip at Node's default level; kB here means 1,024 bytes.
Install

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
defuss-morph.min.js 19.2 kB · ESM · this build

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
Portrait of Aron Homberg

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.

Talk to Aron on LinkedIn (opens in a new tab)

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