Rich Text Editor
A lightweight WYSIWYG editor with a formatting toolbar, sanitized paste, an HTML source view, and keyboard shortcuts.
Copy → Paste → Ship
Live Preview
Vanilla JS · Zero Dependencies · Sanitized PasteLightweight WYSIWYG
Select this text and try the toolbar — make it bold, italic, or add a link.
- Paste from a doc and watch it get sanitized
- Toggle the
</>source view to see clean HTML
Every formatting button maps to one execCommand call.
Try pasting rich content from another page — scripts, inline styles, and unknown tags are stripped to a safe allowlist before they ever hit the document.
The Prompt
Build a lightweight WYSIWYG rich text editor using vanilla HTML, CSS, and JavaScript. No libraries, no frameworks — zero dependencies. Target: a few kilobytes, not a TinyMCE/Quill-scale editor.
CORE ARCHITECTURE
- A single contenteditable="true" element is the editing surface
- A toolbar of buttons above it triggers formatting commands
- Route ALL formatting through one exec(command, value) helper that wraps document.execCommand, so the editor has a single seam to swap out later (execCommand is deprecated but still universally supported and has no drop-in replacement yet)
- After any command, re-focus the surface and re-sync the toolbar state
TOOLBAR (each button maps to one command)
- Bold (execCommand 'bold'), Italic ('italic'), Underline ('underline'), Strikethrough ('strikeThrough')
- Headings: 'formatBlock' with h1 / h2, and a paragraph button ('formatBlock' p)
- Bulleted list ('insertUnorderedList'), Numbered list ('insertOrderedList')
- Blockquote ('formatBlock' blockquote)
- Insert link ('createLink') — prompt for a URL and VALIDATE it (see SECURITY)
- Clear formatting ('removeFormat')
- Toggle HTML source view (see SOURCE VIEW)
VISUAL DESIGN
- Editor container: 1px border at rgba(255,255,255,0.1), border-radius 12px, dark translucent background, overflow hidden so the toolbar and surface share one rounded frame
- Toolbar: flex-wrap row, 8px padding, bottom border; buttons are 30px tall, 7px border-radius, transparent until hover; thin vertical separators between logical groups
- Active state (when queryCommandState is true): cyan tint background (rgba(0,196,255,0.12)), cyan border, cyan text (#00c4ff)
- Surface: min-height ~220px, comfortable 18–20px padding, 14px / 1.65 line-height; styled h1/h2/h3, lists, blockquote (cyan left border), inline code, and links (cyan, underlined)
- Empty-state placeholder via :empty::before reading a data-placeholder attribute
- prefers-reduced-motion: drop button transitions
TOOLBAR STATE SYNC
- On keyup, mouseup, and focus within the surface (or on document 'selectionchange'), loop the toolbar buttons and call document.queryCommandState(command) for each
- Reflect the result on BOTH the visual is-active class AND aria-pressed on the button
- Wrap queryCommandState in try/catch — some commands (like formatBlock) throw or return unreliably across browsers; degrade gracefully
KEYBOARD
- Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+U trigger bold / italic / underline (preventDefault, then call exec)
- Let the browser handle Enter, Backspace, and caret movement natively inside contenteditable
SECURITY (this is the part most editors get wrong — do it properly)
- Sanitize on PASTE: listen for the paste event, preventDefault, read text/html from the clipboard, and parse it inside a DETACHED document (document.implementation.createHTMLDocument) so nothing executes during parsing
- Walk the parsed node tree against a strict ALLOWLIST of tags: p, br, b, strong, i, em, u, s, h1, h2, h3, ul, ol, li, blockquote, code, pre, a
- For each kept element, remove EVERY attribute except a vetted href on anchors; drop all inline style, class, id, and on* event-handler attributes
- Reject dangerous URL schemes: block href values matching javascript:, data:, or vbscript:; allow http(s), mailto, tel, fragment, and relative URLs only. Add rel="noopener nofollow" to surviving links
- Unwrap unknown/disallowed elements to their sanitized text content rather than dropping the text
- If no text/html is present, insert text/plain via insertText
- Run getHTML() output through the SAME sanitizer before returning it, and note clearly that server-side sanitization is still required — client-side sanitizing is defense-in-depth, not a substitute
SOURCE VIEW
- A toggle button swaps the contenteditable surface for a <textarea> showing the current HTML (lightly pretty-printed by breaking between tags)
- Switching back to rich view runs the textarea content through the sanitizer before assigning it to the surface, so hand-edited source can never inject anything
ACCESSIBILITY
- Toolbar: role="toolbar" with aria-label; each button is a real <button> with an aria-label and aria-pressed reflecting state
- Surface: role="textbox", aria-multiline="true", and an aria-label (or aria-labelledby)
- Toolbar buttons call preventDefault on mousedown so clicking them never collapses or steals the selection in the editor
- Maintain a 4.5:1 contrast ratio for toolbar and body text
API (the class)
- new RichTextEditor(rootElement, {
initialHTML: '', // sanitized before insertion
placeholder: 'Write something…',
onChange: (html) => {} // fires on input with sanitized HTML
})
- Public methods: getHTML(), setHTML(html), focus(), clear(), destroy()
- getHTML() and setHTML() both pass through the sanitizer
- destroy() removes all listeners
OUTPUT
- Single HTML file with embedded <style> and <script>
- One live editor pre-filled with a short sample document (a heading, a paragraph with bold/italic/link, a list, and a blockquote)
- Polished, professional dark aesthetic — should feel like the composer in a premium modern app, not a 2009 forum textarea
Anatomy
Contenteditable Surface
One element with contenteditable="true", role="textbox", and aria-multiline does all the editing — no iframe, no shadow DOM.
Command Toolbar
A role="toolbar" row of real buttons, each mapping to a single execCommand routed through one exec() seam.
Live State Sync
queryCommandState drives both the cyan is-active styling and aria-pressed as the caret moves through formatted text.
Allowlist Sanitizer
Pasted HTML is parsed in a detached document and reduced to a safe tag/attribute allowlist before it touches the page.
URL Scheme Guard
Link hrefs are validated — javascript:, data:, and vbscript: are rejected, and survivors get rel="noopener nofollow".
HTML Source View
Toggle the surface for a textarea of clean HTML; switching back re-sanitizes whatever was hand-typed.
Selection-Safe Buttons
Toolbar buttons preventDefault on mousedown so a click never collapses the editor's selection.
Keyboard Shortcuts
Ctrl/Cmd+B, I, and U map to bold, italic, and underline; Enter and caret movement stay native.
Usage Guidelines
Use This When
- You need formatted input — comments, descriptions, notes, or a message composer — where Markdown would be a hurdle for non-technical users
- You want a tiny, dependency-free editor you fully control, rather than shipping a 100 KB+ library for a handful of formatting buttons
- Output is stored as HTML and you can sanitize it again on the server before rendering it back
Not Ideal When
- You need collaborative editing, tables, image uploads, comments, or track-changes — reach for ProseMirror, Lexical, or TipTap instead
- Your content is genuinely plain text — a styled
<textarea>(optionally with Markdown) is simpler and more robust - You can't run server-side sanitization — never trust client-sanitized HTML alone before rendering it to other users
Frequently Asked Questions
How do I build a lightweight rich text editor without a library like TinyMCE or Quill?
Use a single contenteditable element as the editing surface and a toolbar of buttons that call document.execCommand for formatting like bold, italic, lists, and links. Sync the toolbar's active states from document.queryCommandState on every selection change, and sanitize pasted content against an allowlist. This gives you a few-kilobyte editor with zero dependencies; the prompt above outputs the full vanilla-JavaScript implementation.
Is document.execCommand deprecated, and should I still use it?
execCommand is marked deprecated, but every current browser still ships it and there is no drop-in standardized replacement yet, so it remains the pragmatic choice for a lightweight editor. The prompt isolates all execCommand calls behind a single exec() helper, so if you later migrate to the Selection and Range APIs or an input-events model, you only change one function rather than the whole editor.
How do I sanitize pasted HTML to prevent XSS in a contenteditable editor?
Intercept the paste event, read text/html from the clipboard, and parse it in a detached document so nothing executes. Walk the node tree and keep only an allowlist of tags (p, b, strong, i, em, headings, lists, blockquote, code, a), strip every attribute except a vetted href, reject javascript:, data:, and vbscript: URLs, and unwrap any unknown element to its text. The prompt implements exactly this allowlist sanitizer.
How do I keep the toolbar buttons in sync with the current selection?
Listen for keyup, mouseup, and focus on the editor (or the document's selectionchange event) and, for each toolbar command, call document.queryCommandState to learn whether that formatting is active at the caret. Reflect the result by toggling an is-active class and the aria-pressed attribute on the button so both sighted and assistive-technology users see the current state.
How do I get clean HTML output from a contenteditable editor?
Read editor.innerHTML, then run it through the same allowlist sanitizer used for paste before storing or submitting it. Never trust contenteditable output as-is on the server either: sanitize again server-side. The editor also offers a source view that shows the current HTML and re-sanitizes whatever you type there when you switch back to the rich view.
Can I use this rich text editor with React, Vue, or Svelte?
Yes. The output is framework-agnostic vanilla JavaScript exposing a RichTextEditor class. In React, instantiate inside useEffect with a ref and keep React from re-rendering the contenteditable subtree (make it uncontrolled); in Vue, use onMounted; in Svelte, onMount. Read and write content through getHTML() and setHTML() rather than binding innerHTML directly, and call destroy() on unmount to remove listeners.
How do I keep the editor accessible?
Give the toolbar role="toolbar" with an aria-label, make each button a real button with an aria-label and aria-pressed reflecting its state, and expose the editing surface with role="textbox", aria-multiline="true", and an aria-label or aria-labelledby. Standard shortcuts (Ctrl/Cmd+B, I, U) work, and the toolbar buttons use mousedown preventDefault so clicking them never steals the caret from the editor.