If you’re building a block-based editor (think Notion), you’ll eventually try nesting contenteditable elements. It doesn’t work the way you’d expect.
The Problem
<div contenteditable="true" id="editor">
<div class="block" contenteditable="true">
<p>Block 1</p>
</div>
<div class="block" contenteditable="true">
<p>Block 2</p>
</div>
</div>
This breaks: selection bleeds across blocks, Backspace at the start of Block 2 merges it into Block 1, and arrow keys navigate through the outer editable’s continuous text flow instead of jumping between blocks.
You can’t just remove contenteditable from the outer container either — you’d lose native drag-and-drop, unified undo/redo, and Ctrl+A behavior.
Shadow DOM Solution
Shadow DOM creates an encapsulation boundary that resets the editing host. A contenteditable="true" inside a shadow root becomes its own independent editing host.
Define a custom element:
class EditorBlock extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
display: block;
margin: 4px 0;
padding: 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
:host(:focus-within) {
border-color: #4a90d9;
}
#content {
outline: none;
min-height: 1em;
}
</style>
<div id="content" contenteditable="true"><slot></slot></div>
`;
}
}
customElements.define("editor-block", EditorBlock);
Each <editor-block> is contenteditable="false" on the outside (non-editable island), but contenteditable="true" inside its shadow root (independent editing host):
<div contenteditable="true" id="editor">
<editor-block contenteditable="false">
<p>Block 1</p>
</editor-block>
<editor-block contenteditable="false">
<p>Block 2</p>
</editor-block>
</div>
The structure:
Outer editor (contenteditable="true")
│
├── editor-block (contenteditable="false") ← non-editable island
│ └── [Shadow Root]
│ └── #content (contenteditable="true") ← NEW editing host
│ └── <slot> → light DOM content
│
└── editor-block (contenteditable="false")
└── [Shadow Root]
└── #content (contenteditable="true") ← NEW editing host
└── <slot> → light DOM content
Keyboard Navigation Between Blocks
You’ll need to wire up arrow keys yourself since each block is now isolated:
document.getElementById("editor").addEventListener("keydown", e => {
const active = document.activeElement;
if (!active || !active.shadowRoot) return;
if (e.key === "ArrowDown" || e.key === "Enter") {
const next = active.nextElementSibling;
if (next && next.shadowRoot) {
e.preventDefault();
const target = next.shadowRoot.getElementById("content");
target.focus();
const sel = target.getRootNode().getSelection?.() ?? window.getSelection();
sel.collapse(target, 0);
}
}
if (e.key === "ArrowUp") {
const prev = active.previousElementSibling;
if (prev && prev.shadowRoot) {
e.preventDefault();
const target = prev.shadowRoot.getElementById("content");
target.focus();
const sel = target.getRootNode().getSelection?.() ?? window.getSelection();
sel.collapse(target, target.childNodes.length);
}
}
});
Notes
shadowRoot.getSelection()isn’t available in all browsers. The fallbacktarget.getRootNode().getSelection?.() ?? window.getSelection()handles this.- Copy/paste across shadow boundaries may need custom
ClipboardEventhandling. - Drag-and-drop reordering requires handling
dragstart,dragover, anddropevents across shadow roots.