Components Controls & Input RichTextEditor

RichTextEditor

Contenteditable rich-text editor with a formatting toolbar that outputs sanitised HTML.

Live demo

01

Blank editor with live output

Type, then format with the toolbar. The sanitised HTML the component emits appears below.

Sanitised output
(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
RichTextEditor.svelte
<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

03

What 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 innerHTML

The 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 explainer

API

04
PropTypeDefaultDescription
valuestring (bindable)''Sanitised HTML content. Reading gives clean HTML; setting it (while unfocused) replaces the editor body.
placeholderstring'Start writing…'Shown when the editor has no meaningful content.
onChange(html: string) => voidundefinedFires with the sanitised HTML after each edit that changes the content.
ariaLabelstring'Rich text editor'Accessible name for the editable region.
disabledbooleanfalseDisables editing and the toolbar; greys the surface.