Skip to content
sang.id.vn
Go back

Nested contenteditable: Using Shadow DOM to Transfer the Editing Host

Tiếng Việt

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


Share this post on:

Previous Post
Big Bang vs. Sharing Economy — A Surprisingly Perfect Analogy
Next Post
The float and fixed Positioning Trap in contenteditable