Contenteditable & Rich Text
contenteditable makes an element’s DOM subtree editable by the user. It powers comments, light rich-text fields, and some CMS surfaces. It is deceptively hard: browsers disagree on generated markup, caret behavior, and paste handling. Serious editors usually sit on libraries that normalize those differences.
This guide covers the attribute and values, designMode, the legacy document.execCommand API versus modern Selection/Range approaches, paste sanitization, accessibility, security, and how to decide between native editing and a library.
contenteditable is a global attribute. Values: empty string or true (editable), false (not editable), and plaintext-only (where supported — editable as plain text without rich formatting). Inheritance: descendants can override with contenteditable="false" for read-only islands.
| 1 | <div contenteditable="true" role="textbox" aria-multiline="true" aria-label="Bio"> |
| 2 | Write a short bio… |
| 3 | </div> |
| 4 | |
| 5 | <div contenteditable="true"> |
| 6 | Editable |
| 7 | <span contenteditable="false" class="mention">@ada</span> |
| 8 | continues… |
| 9 | </div> |
| 10 | |
| 11 | <!-- Plain text only (support varies) --> |
| 12 | <div contenteditable="plaintext-only"></div> |
| Value | Meaning |
|---|---|
| true / "" | Rich editable (browser-defined) |
| false | Not editable (overrides ancestor) |
| plaintext-only | No rich formatting where supported |
best practice
document.designMode = "on" makes the entire document editable — a historical pathway for iframe-based editors. Prefer scoped contenteditable on a single root rather than turning on designMode for the whole page.
| 1 | const iframe = document.querySelector('iframe'); |
| 2 | const doc = iframe.contentDocument; |
| 3 | doc.designMode = 'on'; |
| 4 | doc.body.innerHTML = '<p>Edit me</p>'; |
warning
document.execCommand was the classic rich-text API (bold, italic, insertHTML, etc.). It is obsolete in the specification sense — still present in browsers but inconsistent and frozen. New editors should prefer Selection/Range manipulation, Input Events, and custom document models (or libraries).
| Approach | Pros | Cons |
|---|---|---|
| execCommand | Tiny code for demos | Inconsistent HTML; obsolete |
| Selection/Range + DOM | Precise control | You own every edge case |
| beforeinput handlers | Cancel/replace user intent | Browser gaps remain |
| Library (ProseMirror, Lexical, TipTap, Quill, CKEditor) | Battle-tested model | Bundle weight / learning curve |
| 1 | // Legacy demo — avoid in new production editors |
| 2 | document.execCommand('bold', false, null); |
| 3 | document.execCommand('formatBlock', false, 'h2'); |
| 4 | document.execCommand('insertHTML', false, '<strong>x</strong>'); |
| 5 | |
| 6 | // queryCommandState is similarly legacy |
| 7 | const pressed = document.queryCommandState('bold'); |
| 1 | // Modern direction: listen to beforeinput and map to a model |
| 2 | editor.addEventListener('beforeinput', (e) => { |
| 3 | if (e.inputType === 'formatBold') { |
| 4 | e.preventDefault(); |
| 5 | view.dispatch(view.state.tr.toggleMark(schema.marks.strong)); |
| 6 | } |
| 7 | }); |
danger
note
User carets and highlights are represented by Selection (usually one range in most browsers for editable content). Range points to boundary positions within the DOM. Editing operations read the selection, mutate the DOM, then restore a sensible caret.
| 1 | const sel = window.getSelection(); |
| 2 | if (!sel.rangeCount) return; |
| 3 | const range = sel.getRangeAt(0); |
| 4 | |
| 5 | // Wrap selection in <strong> |
| 6 | const strong = document.createElement('strong'); |
| 7 | range.surroundContents(strong); // throws if range partially selects non-text nodes |
| 8 | |
| 9 | // Safer pattern: extractContents → wrap → insert |
| 10 | function wrapInline(tagName) { |
| 11 | const sel = getSelection(); |
| 12 | if (!sel.rangeCount || sel.isCollapsed) return; |
| 13 | const range = sel.getRangeAt(0); |
| 14 | const el = document.createElement(tagName); |
| 15 | el.appendChild(range.extractContents()); |
| 16 | range.insertNode(el); |
| 17 | sel.removeAllRanges(); |
| 18 | const after = document.createRange(); |
| 19 | after.selectNodeContents(el); |
| 20 | after.collapse(false); |
| 21 | sel.addRange(after); |
| 22 | } |
| 1 | // Caret coordinates for floating toolbars |
| 2 | const range = getSelection().getRangeAt(0); |
| 3 | const rect = range.getBoundingClientRect(); |
| 4 | toolbar.style.transform = `translate(${rect.left}px, ${rect.top - 40}px)`; |
info
pro tip
Paste is the #1 source of disastrous markup: Word classes, nested spans, scripts, and styles. Intercept paste events, read clipboardData, sanitize, and insert clean HTML or plain text.
| 1 | editor.addEventListener('paste', (e) => { |
| 2 | e.preventDefault(); |
| 3 | const html = e.clipboardData.getData('text/html'); |
| 4 | const text = e.clipboardData.getData('text/plain'); |
| 5 | |
| 6 | if (html) { |
| 7 | const clean = sanitize(html); // DOMPurify allowlist |
| 8 | insertHtmlAtSelection(clean); |
| 9 | } else { |
| 10 | insertTextAtSelection(text); |
| 11 | } |
| 12 | }); |
| 13 | |
| 14 | function insertTextAtSelection(text) { |
| 15 | const sel = getSelection(); |
| 16 | if (!sel.rangeCount) return; |
| 17 | const range = sel.getRangeAt(0); |
| 18 | range.deleteContents(); |
| 19 | range.insertNode(document.createTextNode(text)); |
| 20 | range.collapse(false); |
| 21 | } |
| Clipboard type | Typical use |
|---|---|
| text/plain | Safest default |
| text/html | Rich paste after sanitize |
| Files / images | Upload pipeline, not raw data URLs blindly |
best practice
danger
Editable regions must be keyboard operable, named, and announced. Screen readers vary in how they treat contenteditable. Prefer native textarea for plain multi-line text when rich formatting is not required.
| Requirement | Implementation |
|---|---|
| Name | aria-label / aria-labelledby |
| Role | role="textbox" + aria-multiline="true" |
| Disabled | aria-disabled + contenteditable=false |
| Required | aria-required="true" |
| Description | aria-describedby to help text |
| Keyboard | Tab moves focus; shortcuts documented |
| 1 | <label id="lbl-notes" for="notes">Meeting notes</label> |
| 2 | <div |
| 3 | id="notes" |
| 4 | class="editor" |
| 5 | contenteditable="true" |
| 6 | role="textbox" |
| 7 | aria-multiline="true" |
| 8 | aria-labelledby="lbl-notes" |
| 9 | aria-describedby="notes-hint" |
| 10 | ></div> |
| 11 | <p id="notes-hint">Use Ctrl+B for bold. Paste is sanitized.</p> |
warning
best practice
Rich text is an HTML injection surface. Stored XSS appears when you render saved HTML later without sanitization. Attackers use paste, drag-and-drop, and execCommand insertHTML.
| 1 | Threats: |
| 2 | - Stored XSS via saved editor HTML |
| 3 | - javascript: links and data: URLs |
| 4 | - SVG/MathML script islands |
| 5 | - CSS-based data theft in style attrs |
| 6 | - Privilege escalation via admin-only CMS fields |
| 7 | |
| 8 | Controls: |
| 9 | - Sanitize on input AND on output (defense in depth) |
| 10 | - CSP disallowing inline scripts |
| 11 | - Restrict allowed tags (p, strong, em, ul, ol, li, a[href]) |
| 12 | - Normalize href to https:/mailto:/relative only |
| 13 | - Escape when converting to other contexts |
danger
Use native contenteditable alone only for small, low-stakes plain-ish fields. Choose a library when you need collaborative editing, complex schemas, reliable undo, or consistent markup.
| Situation | Recommendation |
|---|---|
| Single-line rename | input |
| Multi-line plain comment | textarea |
| Bold/italic notes, tiny | contenteditable + sanitize (careful) |
| Docs / knowledge base | TipTap, Lexical, ProseMirror, CKEditor, Slate |
| Google Docs-scale | Custom CRDT + editor framework |
| 1 | // Decision sketch |
| 2 | function pickEditor({ rich, collab, forms }) { |
| 3 | if (!rich && forms) return 'textarea'; |
| 4 | if (!rich) return 'textarea'; |
| 5 | if (collab || rich === 'complex') return 'prosemirror-or-lexical'; |
| 6 | return 'contenteditable-minimal'; |
| 7 | } |
info
The beforeinput / input events expose inputType values like insertText, insertFromPaste, formatBold, deleteContentBackward. Preventing beforeinput lets you implement custom editing without fighting keydown maps alone.
| 1 | editor.addEventListener('beforeinput', (e) => { |
| 2 | switch (e.inputType) { |
| 3 | case 'insertFromPaste': |
| 4 | e.preventDefault(); |
| 5 | handlePaste(e); |
| 6 | break; |
| 7 | case 'formatBold': |
| 8 | e.preventDefault(); |
| 9 | toggleBold(); |
| 10 | break; |
| 11 | default: |
| 12 | break; |
| 13 | } |
| 14 | }); |
note
Browser undo stacks for contenteditable are opaque and break when you mutate the DOM in unusual ways. Robust editors keep an immutable document model and implement undo themselves.
| 1 | // Conceptual model |
| 2 | const history = []; |
| 3 | function commit(nextDoc) { |
| 4 | history.push(currentDoc); |
| 5 | currentDoc = nextDoc; |
| 6 | render(currentDoc); |
| 7 | } |
| 8 | function undo() { |
| 9 | if (!history.length) return; |
| 10 | currentDoc = history.pop(); |
| 11 | render(currentDoc); |
| 12 | } |
| 1 | [ ] Prefer textarea/input when rich text is unnecessary |
| 2 | [ ] Name the editor for assistive tech |
| 3 | [ ] Sanitize paste and stored HTML |
| 4 | [ ] Avoid new execCommand-based architectures |
| 5 | [ ] Use Selection/Range carefully; test misnested cases |
| 6 | [ ] Document keyboard shortcuts |
| 7 | [ ] CSP + href allowlists on rendered links |
| 8 | [ ] Pick a library for non-trivial editing |
Input Method Editors (IME) for CJK and other languages compose text through intermediate states. Do not mutate the DOM aggressively during composition. Listen for compositionstart / compositionend and pause normalizing until composition finishes.
| 1 | let composing = false; |
| 2 | editor.addEventListener('compositionstart', () => { composing = true; }); |
| 3 | editor.addEventListener('compositionend', () => { |
| 4 | composing = false; |
| 5 | normalizeIfNeeded(); |
| 6 | }); |
| 7 | editor.addEventListener('input', () => { |
| 8 | if (composing) return; |
| 9 | normalizeIfNeeded(); |
| 10 | }); |
warning
Decide what you store: HTML, Markdown, or a JSON document model. HTML is ubiquitous but messy. Markdown is friendly for git diffs but lossy for complex formatting. JSON schemas (ProseMirror, Lexical) are best for collaborative editing and schema enforcement.
| Format | Strength | Weakness |
|---|---|---|
| HTML | Easy to render | XSS + inconsistency |
| Markdown | Readable diffs | Limited structure |
| JSON model | Schema + collab | Needs renderer |
| 1 | // Persist HTML (sanitize on write and read) |
| 2 | const html = DOMPurify.sanitize(editor.innerHTML, { USE_PROFILES: { html: true } }); |
| 3 | await api.save({ html }); |
| 4 | |
| 5 | // Prefer model when using a library |
| 6 | await api.save({ doc: view.state.doc.toJSON() }); |
Toolbars should use real <button type="button"> elements with aria-pressed for toggles. Prevent mousedown default on toolbar buttons so the selection in the editor does not collapse when clicking the toolbar.
| 1 | toolbar.addEventListener('mousedown', (e) => { |
| 2 | if (e.target.closest('button')) e.preventDefault(); |
| 3 | }); |
| 4 | |
| 5 | boldBtn.addEventListener('click', () => { |
| 6 | toggleBold(); |
| 7 | boldBtn.setAttribute( |
| 8 | 'aria-pressed', |
| 9 | String(isBoldActive()) |
| 10 | ); |
| 11 | }); |
best practice
Unit-test the document model transforms. End-to-end test caret flows sparingly — they are flaky across browsers. Snapshot serialized output for bold/italic/list operations and paste fixtures from Word/Google Docs.
| 1 | Fixtures to keep: |
| 2 | - plain text paste |
| 3 | - HTML paste with <script> and onerror |
| 4 | - nested lists |
| 5 | - partial-select bold across elements |
| 6 | - IME composition string |
| 7 | - empty editor placeholder announcements |
Mentions, embeds, and payment chips are often modeled as contenteditable="false" islands inside an editable root. Users should delete them atomically (one backspace removes the chip). Implement deletion by detecting caret adjacency and removing the whole island node.
| 1 | <div contenteditable="true" class="editor"> |
| 2 | Hello |
| 3 | <span class="chip" contenteditable="false" data-user-id="42">@ada</span> |
| 4 | ! |
| 5 | </div> |
| 1 | editor.addEventListener('keydown', (e) => { |
| 2 | if (e.key !== 'Backspace') return; |
| 3 | const sel = getSelection(); |
| 4 | if (!sel.isCollapsed) return; |
| 5 | const node = sel.anchorNode; |
| 6 | const el = node.nodeType === 1 ? node : node.parentElement; |
| 7 | const chip = el?.previousElementSibling?.classList?.contains('chip') |
| 8 | ? el.previousElementSibling |
| 9 | : null; |
| 10 | // Real implementations use better caret geometry — sketch only |
| 11 | }); |
info
Contenteditable regions are not successful form controls. Mirror values into a hidden <input> / <textarea> on submit, or submit via JavaScript. Constraint validation does not apply to the editable div itself.
| 1 | <form id="f"> |
| 2 | <div id="ed" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Comment"></div> |
| 3 | <textarea name="comment" id="mirror" hidden></textarea> |
| 4 | <button type="submit">Post</button> |
| 5 | </form> |
| 6 | <script> |
| 7 | document.getElementById('f').addEventListener('submit', () => { |
| 8 | mirror.value = document.getElementById('ed').innerText.trim(); |
| 9 | }); |
| 10 | </script> |
note
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.