|$ curl https://forge-ai.dev/api/markdown?path=docs/html/contenteditable
$cat docs/contenteditable-&-rich-text.md
updated Today·38 min read·published

Contenteditable & Rich Text

HTMLContenteditableJavaScriptAdvancedAdvanced🎯Free Tools
Introduction

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.

The contenteditable attribute

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.

ce-basic.html
HTML
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>
ValueMeaning
true / ""Rich editable (browser-defined)
falseNot editable (overrides ancestor)
plaintext-onlyNo rich formatting where supported
preview

best practice

Always provide an accessible name (aria-label / aria-labelledby) and usually role="textbox" with aria-multiline="true" for multi-line editors.
designMode

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.

designmode.js
JavaScript
1const iframe = document.querySelector('iframe');
2const doc = iframe.contentDocument;
3doc.designMode = 'on';
4doc.body.innerHTML = '<p>Edit me</p>';

warning

designMode editors are harder to sandbox and style. Most modern libraries edit a contenteditable root in the host document or a carefully controlled iframe.
execCommand legacy vs modern approaches

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

ApproachProsCons
execCommandTiny code for demosInconsistent HTML; obsolete
Selection/Range + DOMPrecise controlYou own every edge case
beforeinput handlersCancel/replace user intentBrowser gaps remain
Library (ProseMirror, Lexical, TipTap, Quill, CKEditor)Battle-tested modelBundle weight / learning curve
execcommand.js
JavaScript
1// Legacy demo — avoid in new production editors
2document.execCommand('bold', false, null);
3document.execCommand('formatBlock', false, 'h2');
4document.execCommand('insertHTML', false, '<strong>x</strong>');
5
6// queryCommandState is similarly legacy
7const pressed = document.queryCommandState('bold');
beforeinput.js
JavaScript
1// Modern direction: listen to beforeinput and map to a model
2editor.addEventListener('beforeinput', (e) => {
3 if (e.inputType === 'formatBold') {
4 e.preventDefault();
5 view.dispatch(view.state.tr.toggleMark(schema.marks.strong));
6 }
7});

danger

execCommand("insertHTML") is an XSS sink if fed unsanitized HTML.
📝

note

Browser vendors maintain execCommand for compatibility; feature quality will not improve.
Selection and Range APIs

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.

selection.js
JavaScript
1const sel = window.getSelection();
2if (!sel.rangeCount) return;
3const range = sel.getRangeAt(0);
4
5// Wrap selection in <strong>
6const strong = document.createElement('strong');
7range.surroundContents(strong); // throws if range partially selects non-text nodes
8
9// Safer pattern: extractContents → wrap → insert
10function 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}
caret-rect.js
JavaScript
1// Caret coordinates for floating toolbars
2const range = getSelection().getRangeAt(0);
3const rect = range.getBoundingClientRect();
4toolbar.style.transform = `translate(${rect.left}px, ${rect.top - 40}px)`;

info

surroundContents fails when the range splits elements unevenly — catch and fall back to extractContents patterns or a real editor model.
🔥

pro tip

Store positions as document-model offsets (libraries do this), not as live Range objects, across asynchronous work.
Paste sanitization

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.

paste.js
JavaScript
1editor.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
14function 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 typeTypical use
text/plainSafest default
text/htmlRich paste after sanitize
Files / imagesUpload pipeline, not raw data URLs blindly

best practice

Offer “Paste as plain text” (Ctrl/Cmd+Shift+V) behavior as the default for comment UIs.

danger

Never insert clipboard HTML without an allowlist sanitizer. Office HTML is hostile.
Accessibility for editable regions

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.

RequirementImplementation
Namearia-label / aria-labelledby
Rolerole="textbox" + aria-multiline="true"
Disabledaria-disabled + contenteditable=false
Requiredaria-required="true"
Descriptionaria-describedby to help text
KeyboardTab moves focus; shortcuts documented
a11y.html
HTML
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

Fake placeholders via empty text + CSS often break SR announcements. Use aria-placeholder where supported and visible cues carefully.

best practice

If you only need plain text, use <textarea> — better mobile keyboards, forms participation, and AT support.
Security

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.

untitled.text
TEXT
1Threats:
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
8Controls:
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

Rendering “already sanitized” HTML from an old database without re-sanitizing is risky after sanitizer upgrades or browser parser changes.
Library vs native

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.

SituationRecommendation
Single-line renameinput
Multi-line plain commenttextarea
Bold/italic notes, tinycontenteditable + sanitize (careful)
Docs / knowledge baseTipTap, Lexical, ProseMirror, CKEditor, Slate
Google Docs-scaleCustom CRDT + editor framework
decide.js
JavaScript
1// Decision sketch
2function 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

Libraries still emit HTML or JSON schemas — you still must sanitize HTML at trust boundaries.
Input Events Level 2

The beforeinput / input events expose inputType values like insertText, insertFromPaste, formatBold, deleteContentBackward. Preventing beforeinput lets you implement custom editing without fighting keydown maps alone.

input-events.js
JavaScript
1editor.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

Support differs; always feature-detect and provide fallbacks.
Undo, history, and document models

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.

history.js
JavaScript
1// Conceptual model
2const history = [];
3function commit(nextDoc) {
4 history.push(currentDoc);
5 currentDoc = nextDoc;
6 render(currentDoc);
7}
8function undo() {
9 if (!history.length) return;
10 currentDoc = history.pop();
11 render(currentDoc);
12}
Live mini editor
preview
Checklist
untitled.text
TEXT
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
Carets, IME, and mobile

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.

ime.js
JavaScript
1let composing = false;
2editor.addEventListener('compositionstart', () => { composing = true; });
3editor.addEventListener('compositionend', () => {
4 composing = false;
5 normalizeIfNeeded();
6});
7editor.addEventListener('input', () => {
8 if (composing) return;
9 normalizeIfNeeded();
10});

warning

Mobile browsers virtualize carets differently. Test iOS Safari and Android Chrome early — floating toolbars based on getBoundingClientRect often need viewport scroll compensation.
Serialization formats

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.

FormatStrengthWeakness
HTMLEasy to renderXSS + inconsistency
MarkdownReadable diffsLimited structure
JSON modelSchema + collabNeeds renderer
serialize.js
JavaScript
1// Persist HTML (sanitize on write and read)
2const html = DOMPurify.sanitize(editor.innerHTML, { USE_PROFILES: { html: true } });
3await api.save({ html });
4
5// Prefer model when using a library
6await api.save({ doc: view.state.doc.toJSON() });
Toolbar patterns

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.

toolbar.js
JavaScript
1toolbar.addEventListener('mousedown', (e) => {
2 if (e.target.closest('button')) e.preventDefault();
3});
4
5boldBtn.addEventListener('click', () => {
6 toggleBold();
7 boldBtn.setAttribute(
8 'aria-pressed',
9 String(isBoldActive())
10 );
11});

best practice

Mirror the HTML Buttons guide: icon-only toolbar controls need accessible names.
Testing editable UIs

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.

untitled.text
TEXT
1Fixtures 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
Read-only islands and widgets

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.

chip.html
HTML
1<div contenteditable="true" class="editor">
2 Hello
3 <span class="chip" contenteditable="false" data-user-id="42">@ada</span>
4 !
5</div>
chip-delete.js
JavaScript
1editor.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

Libraries excel here: atomic nodes / inline atoms are first-class in ProseMirror and Lexical.
Form participation

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.

form-mirror.html
HTML
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

Hidden mirrors should store sanitized HTML or plain text intentionally — document which one your API expects.
$Blueprint — Engineering Documentation·Section ID: HTML-CONTENTEDITABLE·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.