Rich Text Editor
A lightweight WYSIWYG editor with a formatting toolbar, sanitized paste, an HTML source view, and keyboard shortcuts.
Live demo
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
- CopyThe full prompt, one click.
- PasteInto Cursor, Claude Code, or any agent.
- CompareAgainst the live demo above.
What’s Inside
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