diff --git a/AGENTS.md b/AGENTS.md
index 7b0cb5e..9b9abf2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,12 +33,12 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
- The constructor deep-clones `static props`, records each prop's `typeof` in a private `#typeMap`, reflects defaults to attributes, and wraps the props object in a **Proxy** (`#handler`). Writing `this.props.someProp = x` serializes the value and calls `setAttribute` (kebab-case), which triggers `attributeChangedCallback` → `render()`. That attribute-write-driven cycle *is* the reactivity model — there is no separate scheduler.
- The Proxy enforces type stability against `#typeMap` and throws `TypeError` on a type change.
- Lifecycle hooks authors may override: `onInit()` (connected), `afterViewInit()` (after first render), `onDestroy()` (disconnected), `onChanges({property, previousValue, currentValue})` (attribute changed). Note `onChanges` currently receives the **kebab-case** `property`.
- - `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, rebuilt via `createElement` + `replaceChildren`. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
+ - `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, built via `createElement` + `replaceChildren` on the *first* render and **reconciled in place** (`patchChildren`, see `src/utils/patch.mjs`) on every render after that, so focus/caret/uncommitted input values survive. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
- `static styles` are applied as a constructable `CSSStyleSheet` via `adoptedStyleSheets`, which **only works with a shadow root** — set `static shadowRootInit` to opt into shadow DOM.
- **`src/html.js`** — the `html` tagged-template function. It is a bundled/minified copy of [`htm`](https://github.com/developit/htm) bound to a hyperscript `h(type, props, children)` that produces plain `{type, props, children}` vnodes. License lives in `vendors/htm/`.
-- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), and `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects). `src/utils/index.js` re-exports all of them.
+- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects — its `applyProp` is the single source of truth for the prop→DOM rule), and `patch` (index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/**removals** via `applyProp`, and trims trailing nodes). `src/utils/index.js` re-exports all of them.
## Testing notes
diff --git a/README.md b/README.md
index 1573ccd..4fc2ad5 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,10 @@ When you extend the `WebComponent` class for your component, you only have to de
The result is a reactive UI on property changes.
+When the `template` is an `html` tagged template (a vnode tree), a re-render **patches the existing DOM in place** instead of rebuilding it: elements of the same tag are reused, only changed props/attributes/text are touched, and leftover nodes are trimmed. Transient DOM state on the rendered nodes therefore survives a re-render — focus, caret/selection, an ``'s uncommitted value, `:hover`, and running CSS transitions. A controlled text field can write every keystroke back to a prop without losing focus. The first render still builds the tree from scratch, and string templates still assign to `innerHTML`.
+
+Patching is **index-based and non-keyed**: list items are matched by position, not identity. The resulting DOM is always correct, but preserved state follows the *position* rather than the *item* — remove the first row of a list and the node that held row 1 is rewritten to hold row 2, so a caret, an uncommitted value, or a running transition stays where it was while the content shifts under it. Fixed-order lists and append/remove-at-the-end are unaffected; reorderable lists of focusable or animated items are the sharp edge. A `key` prop for identity-based matching is not implemented.
+
## Want to get in touch?
There are many ways to get in touch:
diff --git a/demo/examples/render-reconciliation/index.html b/demo/examples/render-reconciliation/index.html
new file mode 100644
index 0000000..85fcb05
--- /dev/null
+++ b/demo/examples/render-reconciliation/index.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+ Render reconciliation
+
+
+
+
+
+
+
+
Render reconciliation
+
+ Re-renders of an html template patch the existing DOM in
+ place instead of rebuilding it, so transient DOM state on the rendered
+ nodes survives. Each section below demonstrates one part of that.
+
+
+
1. Focus, caret and uncommitted value survive
+
+ A controlled field: every keystroke writes back to a prop and re-renders
+ the component, yet the <input> is the same element
+ throughout.
+
+
+
+
2. Attribute-only changes patch in place
+
+ Selecting a tab changes only aria-selected and
+ class. Reach a tab with the keyboard, press Enter, and focus
+ stays on it — no node was replaced.
+
+
+
+
3. Running CSS transitions are not restarted
+
+ The box animates for 2s while the component re-renders ten times. Its
+ vnode is unchanged, so the element is left untouched and the transition
+ runs to completion.
+
+
+
+
4. Removed props and attributes are undone
+
+ Props that disappear from the new tree are actively reset on the reused
+ element — a dropped disabled, data-* attribute
+ and inline style rules all go away.
+
+
+
+
5. Lists — and the non-keyed limitation
+
+ Appending and removing reuses the surviving rows. Reordering, however, is
+ matched by position: the rendered result is correct, but
+ preserved state stays with the slot rather than following the item.
+
+
+
+
diff --git a/demo/examples/render-reconciliation/index.js b/demo/examples/render-reconciliation/index.js
new file mode 100644
index 0000000..60183f5
--- /dev/null
+++ b/demo/examples/render-reconciliation/index.js
@@ -0,0 +1,216 @@
+// @ts-check
+import { WebComponent, html } from 'web-component-base'
+
+/**
+ * 1. A controlled text field.
+ *
+ * Every keystroke writes back to a prop, which re-renders the component. The
+ * `` element is reused by the reconciler, so focus, the caret position
+ * and the typed-but-uncommitted value all survive the re-render. Before
+ * in-place patching this rebuilt the input and kicked the user out of the field
+ * on the first character.
+ */
+class ControlledInput extends WebComponent {
+ static props = { value: '' }
+
+ get template() {
+ return html`
+
+ (this.props.value = e.target.value)}
+ />
+
+ Prop value:
+
+
Renders: ${this.#renders}
+ `
+ }
+
+ #renders = 0
+ render() {
+ this.#renders++
+ super.render()
+ }
+}
+customElements.define('controlled-input', ControlledInput)
+
+/**
+ * 2. Attribute-only changes patch in place.
+ *
+ * Selecting a tab changes nothing but `aria-selected` and `class` on the
+ * existing buttons — the elements themselves are reused, so a tab you reached
+ * with the keyboard keeps focus after selection. Tab into the strip and use
+ * the arrow-free Tab/Enter or Space to see focus persist.
+ */
+class SegmentedControl extends WebComponent {
+ static props = { value: 'one' }
+
+ get template() {
+ const tab = (id, label) => html`
+
+ `
+ return html`
+
+ `
+ }
+}
+customElements.define('segmented-control', SegmentedControl)
+
+/**
+ * 3. Unrelated re-renders don't restart a CSS transition.
+ *
+ * The box runs a 2s width transition. Bumping the counter re-renders the whole
+ * component every 200ms, but the box's vnode is unchanged, so its element is
+ * left completely untouched and the transition keeps running instead of
+ * snapping back to the start.
+ */
+class TransitionSafe extends WebComponent {
+ static props = { ticks: 0, wide: false }
+
+ // self-contained so the transition is guaranteed present wherever the
+ // component is used — including the e2e spec, which loads no page CSS
+ static shadowRootInit = { mode: 'open' }
+ static styles = `
+ .anim-box {
+ width: 4em;
+ height: 2.5em;
+ background: currentColor;
+ opacity: 0.5;
+ border-radius: 6px;
+ transition: width 2s ease-in-out;
+ }
+ .anim-box.wide { width: 90%; }
+ `
+
+ get template() {
+ return html`
+
+
+ Unrelated re-renders while it animates:
+
+
+
+ `
+ }
+
+ #run() {
+ this.props.wide = !this.props.wide
+ // hammer the component with unrelated re-renders during the transition
+ let n = 0
+ const id = setInterval(() => {
+ this.props.ticks++
+ if (++n >= 10) clearInterval(id)
+ }, 200)
+ }
+}
+customElements.define('transition-safe', TransitionSafe)
+
+/**
+ * 4. Prop and attribute removal.
+ *
+ * A prop that disappears from the new vnode is actively undone, not left
+ * behind: a dropped `disabled` goes back to `false`, a dropped `data-*`
+ * attribute is removed, and dropped `style` rules are cleared.
+ */
+class PropRemoval extends WebComponent {
+ static props = { decorated: true }
+
+ get template() {
+ const on = this.props.decorated
+ // spread so the props are genuinely *absent* when off — that's the removal
+ // path: present in the old vnode, gone from the new one
+ const decoration = on
+ ? {
+ disabled: true,
+ 'data-badge': 'decorated',
+ style: { outline: '2px solid currentColor', padding: '0.6em' },
+ }
+ : {}
+ return html`
+
+
+ disabled, data-badge and the inline
+ style are present only while decorated — toggling removes
+ them from the same element instead of building a new one.
+
+
+ `
+ }
+}
+customElements.define('prop-removal', PropRemoval)
+
+/**
+ * 5. Lists — including the non-keyed limitation.
+ *
+ * Growing and shrinking reuses the surviving rows. But patching is index-based,
+ * so a *reorder* matches nodes by position, not identity: the DOM ends up
+ * correct, yet each row keeps its slot and has its content rewritten. Type into
+ * a row, then reorder, and you'll see the caret stay on the position rather
+ * than follow the item.
+ */
+class ItemList extends WebComponent {
+ static props = { items: ['alpha', 'bravo', 'charlie'] }
+
+ get template() {
+ return html`
+
+ ${this.props.items.map(
+ (item) => html`
+
+ ${item}
+
+
+ `
+ )}
+
+
+
+
+
+ Type a note into the first row, then press Reverse: the labels
+ swap but your note stays in row 1, because rows are matched by position.
+ A key prop would be needed to make state follow the item.
+