More Forms Snippets
Rich Text Editor — Free HTML CSS JS WYSIWYG Snippet
Rich Text Editor · Forms · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Rich Text Editor — WYSIWYG contenteditable Toolbar, Active States, Link Insertion & HTML Output

A rich text editor is one of the most requested UI components for web apps — comment boxes (add an @mention autocomplete), blog editors (or a markdown live preview), email composers, note-taking tools, and form text areas all benefit from giving users basic formatting control. Building one from scratch with pure HTML, CSS, and vanilla JavaScript is entirely possible using the browser's built-in contenteditable attribute and the document.execCommand() API. This snippet delivers a complete, production-quality WYSIWYG editor: a styled toolbar with 14 controls, active state tracking, inline link insertion, a live character count, and an HTML output viewer — with zero dependencies.
How contenteditable and execCommand work
The contenteditable="true" attribute on a div turns it into an editable region. The browser manages its own internal selection and editing state. document.execCommand(commandName, false, value) operates on the current selection inside the focused contenteditable element. Commands like bold, italic, underline, and strikeThrough toggle inline formatting tags (<b>, <i>, <u>, <strike>) around the selected text. formatBlock wraps the current block in a heading (H1, H2) or paragraph tag. insertOrderedList and insertUnorderedList convert the current paragraph to a list. justifyLeft, justifyCenter, and justifyRight set the text-align of the current block.
Active toolbar state with queryCommandState
document.queryCommandState(commandName) returns true when the current selection or cursor position is inside text that has that formatting applied. The updateToolbar() function iterates over every toolbar button with a data-cmd attribute and calls queryCommandState to toggle the .active CSS class. This function is called on every keyup, mouseup, and selectionchange event — so the toolbar always reflects the formatting at the cursor. When the user clicks inside a bold word, the Bold button immediately appears pressed.
Inline link insertion with selection preservation
Inserting a link requires the user to first select text, then open the link input row without losing the selection. When the link button is clicked, the current selection range is cloned and stored in savedRange using window.getSelection().getRangeAt(0).cloneRange(). The link input row slides open and focuses the URL input. When the user clicks Insert (or presses Enter), the saved range is restored via sel.removeAllRanges(); sel.addRange(savedRange) before calling exec('createLink', url). After insertion, all new <a> elements without a target get target="_blank" and rel="noopener noreferrer" applied automatically.
Placeholder text without a real input
A contenteditable div does not support the native placeholder attribute. This snippet overlays an absolutely positioned div.placeholder on top of the editor area. The syncPlaceholder() function hides it (adds the .hidden class, which sets opacity: 0) whenever editor.textContent.length > 0 or the editor contains any HTML tags. This covers both typed text and formatted blocks like headings or lists that produce HTML but may have no visible characters yet.
Live character count and HTML output
The character count reads editor.textContent.length on every input event — textContent strips all HTML tags and counts only visible characters. The "Get HTML" button reads editor.innerHTML and injects it into a read-only textarea below the editor. The textarea auto-selects on open so the user can copy the HTML immediately. This makes the snippet useful for generating HTML content that will be saved to a database or CMS.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You do not have to work through the selection-preservation trick by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the link button clones the current selection range into a saved variable before the URL input steals focus, and why that saved range must be restored onto window.getSelection before calling the createLink command. The same assistant can help you optimize it — ask whether calling updateToolbar on every keyup, mouseup, and selectionchange event is redundant given they can fire in quick succession, and whether that could be debounced without making the toolbar feel laggy. It's also useful for extending the editor: ask it to add a font-size or text-color dropdown wired through execCommand, implement a real placeholder-clearing fix for headings and lists that produce empty-looking HTML, or add paste handling that strips foreign formatting from pasted content. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a WYSIWYG rich text editor in plain HTML, CSS, and JavaScript using a contenteditable div and document.execCommand — no external editor library, no ProseMirror or Quill.
Requirements:
- A contenteditable div as the editing surface, with a toolbar of buttons for bold, italic, underline, strikethrough, heading levels, ordered and unordered lists, text alignment (left/center/right), and undo/redo, where each button's click calls execCommand with the button's associated command name and optional value, then refocuses the editor.
- Toolbar mousedown handlers must call preventDefault before running the command, so the browser's text selection inside the editable area is never lost when a toolbar button is clicked.
- Implement live active-state highlighting: on every keyup, mouseup, and selectionchange event (while the editor is focused), check queryCommandState for each formatting command and toggle an active visual class on the matching toolbar button, so the toolbar always reflects the formatting under the current cursor position.
- Implement link insertion that preserves the user's text selection across a UI interruption: when the link toolbar button is clicked, clone the current selection range into a saved variable before showing a URL input field (which will steal focus), then on confirming the URL, restore the saved range onto the live selection before calling the createLink command — and afterward force every newly created link without a target attribute to open in a new tab with rel-based security attributes.
- Since a contenteditable element does not support the native placeholder attribute, implement a fake placeholder using an absolutely positioned overlay div that hides itself whenever the editor has any text content or any HTML tags inside it (not just when textContent is non-empty), so headings or lists with no visible text still correctly hide the placeholder.
- Add a live character counter driven by textContent length on the input event, and a button that dumps the editor's current innerHTML into a read-only, auto-selecting textarea for copying.Step by step
How to Use
- 1Click in the editor area and start typingThe placeholder text disappears on first keystroke. Type your content — the editor behaves like a normal text area but produces rich HTML. The character count at the bottom updates live as you type.
- 2Select text and click toolbar buttons to formatHighlight any word or phrase, then click Bold, Italic, Underline, or Strikethrough in the toolbar. The button appears pressed (active state) while the cursor is inside formatted text. Click H1 or H2 to convert the current paragraph to a heading block.
- 3Insert links with the link buttonSelect the text you want to link, then click the link (chain) button in the toolbar. A small input row appears — paste or type the URL and press Insert or Enter. The selected text becomes a clickable link that opens in a new tab. Press Cancel to dismiss without inserting.
- 4Use list and alignment buttonsClick the ordered list button to create a numbered list, or the unordered list button for bullet points. Use the three alignment buttons (left, center, right) to change paragraph alignment. These work on the current block the cursor is in — no selection needed.
- 5Undo and redo changesClick the Undo button to reverse the last change, or Redo to reapply it. These call the browser's native undo stack which tracks every execCommand and keystroke. Standard keyboard shortcuts Ctrl+Z and Ctrl+Y also work inside the editor without any extra code.
- 6Click Get HTML to copy the editor outputPress the "Get HTML" button to reveal a read-only textarea containing the raw HTML from the editor. The textarea auto-selects the content so you can press Ctrl+C to copy it. Use this HTML to save to a database, pass to a backend API, or render in another part of your page.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
execCommand() is marked as "deprecated" in MDN documentation, but browsers continue to support it and have no announced removal timeline — it remains the only reliable cross-browser way to implement WYSIWYG editing without a full framework. Modern editors like Quill and older versions of TipTap use it internally. For production apps requiring advanced formatting (tables, embeds, collaborative editing), a library like TipTap (ProseMirror-based) or Lexical is recommended. For simple formatting — bold, italic, lists, links, headings — execCommand() works reliably in all major browsers.
Read editor.innerHTML for the full rich HTML, or editor.textContent for plain text only. To autosave, add a debounced input event listener: let timer; editor.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { fetch("/api/save", { method: "POST", body: JSON.stringify({ html: editor.innerHTML }), headers: { "Content-Type": "application/json" } }); }, 800); }). Always sanitise the HTML server-side before storing or rendering it — use a library like DOMPurify on the client or a server-side sanitiser to strip script tags and event attributes.
For font size, add a select element to the toolbar: <select id="font-size"><option value="1">Small</option><option value="3">Normal</option><option value="5">Large</option></select> and handle its change event with exec("fontSize", e.target.value). For text colour, add <input type="color" id="text-color"> and handle its input event with exec("foreColor", e.target.value). Both use execCommand() with a value argument. Add the same toolbar button active-state logic by calling document.queryCommandValue("fontSize") and document.queryCommandValue("foreColor") in updateToolbar() to keep the controls in sync with the cursor position.
Never inject editor.innerHTML directly into a page without sanitisation. On the client, run the saved HTML through DOMPurify.sanitize(html) before rendering — this strips script tags, onerror attributes, and javascript: href values. Install DOMPurify via npm or load it from a CDN. On the server (Node.js), use the sanitize-html package with a strict allowedTags list covering only the tags this editor produces: b, i, u, strike, h1, h2, ul, ol, li, a, p, br, div, span. Set allowedAttributes to { a: ["href", "target", "rel"] } and strip all other attributes.