RichTextEditor
Contenteditable rich-text editor with a formatting toolbar that outputs sanitised HTML.
Live demo
01Blank editor with live output
Type, then format with the toolbar. The sanitised HTML the component emits appears below.
(empty)
Seeded content
Pre-filled with a heading, bold/italic runs, a link and a bullet list. Place the caret inside formatted text to see the toolbar reflect the active state.
Disabled (read-only)
With disabled the surface is greyed, the toolbar is inert and the region is
no longer editable.
Implementation
02<script lang="ts">
import RichTextEditor from '$lib/components/RichTextEditor.svelte';
let html = $state('<p>Hello <strong>world</strong></p>');
</script>
β
<RichTextEditor bind:value={html} placeholder="Start writingβ¦" />The editor is a single contenteditable region driven by document.execCommand for formatting (deprecated but still universally implemented). On every edit it parses its own innerHTML inside a detached, inert document, walks the tree against a strict tag allowlist β keeping b/i/u/strong/em/a[href]/h2/ul/ol/li/p/br, unwrapping everything else, and rejecting unsafe link schemes β then emits the cleaned HTML through a bindable value and an onChange callback.
Logic explainer
03What Does It Do? (Plain English)
RichTextEditor gives you a small word-processor inside a box on your page. You type into it like any text field, but a toolbar across the top lets you make words bold, italic, underlined, turn a line into a heading, build bullet or numbered lists, and add links.
The clever part is what comes out. Every time you edit, the component reads the messy HTML the browser produces, runs it through a tiny built-in cleaner, and hands you back a tidy, safe string of HTML. Anything dangerous β a sneaky <script>, an onclick attribute, a javascript: link β is stripped before it can reach your application or your database.
Think of it like: a bouncer on the door of a club. Only a short guest list of tags (b, i, u, strong, em, a, h2, ul, ol, li, p, br) gets in. Everything else is turned away, but its plain text is kept so you never lose what the user actually wrote.
How It Works (Pseudo-Code)
ON every input / formatting action:
1. READ editor.innerHTML (the browser's raw output)
2. PARSE it inside a detached document
(detached = nothing runs, no images load, no scripts fire)
3. WALK the tree node by node:
IF text node β keep the text
IF tag on the allowlist β rebuild it, drop ALL attributes
(except a safe href on <a>)
IF tag NOT allowed β discard the tag, KEEP its children
4. SERIALISE the cleaned tree back to a string
5. IF the string changed β update bind:value AND call onChange(html)
ON selection change (keyup / mouseup / focus):
1. ASK the browser: is bold active? italic? underline?
2. WALK up from the caret: are we inside an H2 / UL / OL?
3. UPDATE the toolbar's aria-pressed + highlight state
ON toolbar button:
bold/italic/underline β execCommand(name)
h2 β formatBlock toggles between <h2> and <p>
ul / ol β execCommand(insert(Un)orderedList)
link β prompt for URL, reject unsafe schemes, createLink
clear β wipe innerHTMLThe Core Concept: Allowlist Sanitisation
A denylist ("block <script>") is a losing game β attackers find tags and encodings you forgot. This component uses the opposite, allowlist approach: nothing is trusted unless it is on a short, explicit list.
dirty HTML
β
ββββββββββΌββββββββββ
β createHTMLDocumentβ β detached, inert
β body.innerHTML β
ββββββββββ¬ββββββββββ
β walk
ββββββββββββββΌββββββββββββββ
βΌ βΌ βΌ
text node allowed tag unknown tag
keep text rebuild clean drop tag,
(strip attrs) keep children
β
<a> special case:
href tested against
isSafeHref()The single non-obvious rule is "unwrap, don't delete": a <div onclick="β¦">hello</div> is not on the list, so the <div> and its attribute vanish β but the text hello is preserved by recursing into the children first. This means pasting from Word or a webpage degrades gracefully to clean text rather than disappearing.
URL Scheme Filtering
Links are the one place an attribute survives, so href gets its own gate:
isSafeHref("https://x.com") β true (http/https)
isSafeHref("mailto:a@b.com") β true (mailto/tel)
isSafeHref("#section") β true (relative / anchor)
isSafeHref("./page") β true (relative)
isSafeHref("javascript:β¦") β false (has a scheme, not allow-listed)
isSafeHref("data:text/html") β false (has a scheme, not allow-listed)The test is: if a string has a scheme: prefix, it must be one we recognise (http, https, mailto, tel); anything else with a scheme is rejected. Schemeless links (relative paths, fragments) are always fine. Surviving anchors also get rel="noopener noreferrer" and target="_blank".
On execCommand (the deprecated workhorse)
document.execCommand and queryCommandState are formally deprecated. They are also still implemented in every shipping browser, and there is no standard replacement for driving contenteditable formatting. The honest alternative is a bespoke Range/Selection engine β hundreds of lines, its own bug surface β which is overkill for a copy-paste template. We use execCommand deliberately, sanitise its output rigorously, and isolate it behind the exec() / toggleBlock() helpers so a future swap touches one place.
State Flow Diagram
ββββββββββββββββ type / format ββββββββββββββββ
β EMPTY β βββββββββββββββββββΆ β EDITING β
β placeholder β β has content β
β visible β βββββββββββββββββββ β β
ββββββββββββββββ clear / delete ββββββββ¬ββββββββ
β every edit
βΌ
βββββββββββββββββββββββ
β sanitise(innerHTML) β
ββββββββββββ¬βββββββββββ
β changed?
βββββββββββββββ΄ββββββββββββββ
βΌ yes βΌ no
value = clean (no emit)
onChange(clean)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string (bindable) |
'' |
Sanitised HTML content. Reading it gives clean HTML; setting it (when unfocused) replaces the editor body. |
placeholder |
string |
'Start writingβ¦' |
Shown when the editor has no meaningful content. |
onChange |
(html: string) => void |
undefined |
Fires after each edit with the sanitised HTML (only when it actually changes). |
ariaLabel |
string |
'Rich text editor' |
Accessible name for the editable region. |
disabled |
boolean |
false |
Disables editing and the toolbar; greys the surface. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Paste from Word / a webpage | Unknown wrappers (<span style>, <div>, <font>) are unwrapped to clean allow-listed HTML or plain text. |
<script> or <style> pasted |
Element discarded entirely (its text content is also dropped for <script>/<style> by the parser). |
onclick, style, class attributes |
Stripped from every element; only a vetted href survives on <a>. |
javascript: / data: link |
Rejected by isSafeHref; the anchor is kept but without an href. |
Empty editor with a stray <br> |
Treated as empty so the placeholder shows. |
External value change while focused |
Ignored β the live caret is never clobbered mid-type. |
| Link prompt cancelled | No change; pressing OK with an empty URL removes the link (unlink). |
disabled |
contenteditable switched off, toolbar buttons disabled, surface greyed. |
Dependencies
Zero external dependencies. Pure Svelte 5 runes, scoped CSS, inline SVG icons, and a hand-rolled allowlist sanitiser built on the platform document.implementation.createHTMLDocument API. No sanitize-html, no DOMPurify, no icon library.
File Structure
RichTextEditor.svelte # The component (editor + toolbar + inline sanitiser)
RichTextEditor.test.ts # Unit tests
RichTextEditor.md # This explainerAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
value | string (bindable) | '' | Sanitised HTML content. Reading gives clean HTML; setting it (while unfocused) replaces the editor body. |
placeholder | string | 'Start writingβ¦' | Shown when the editor has no meaningful content. |
onChange | (html: string) => void | undefined | Fires with the sanitised HTML after each edit that changes the content. |
ariaLabel | string | 'Rich text editor' | Accessible name for the editable region. |
disabled | boolean | false | Disables editing and the toolbar; greys the surface. |