Skip to content

Rich Text Editor

<quiet-rich-text-editor> stable since 6.0 form-associated This component is a form-associated custom element. It will submit its value when given a name and placed inside a <form>.

Provides a rich text editor that stores its value as HTML and integrates with native forms. Reach for it when plain text isn't enough but a full page builder is too much, such as comments, descriptions, and messages.

The editor supports a small set of blocks and inline formats, including paragraphs, headings, lists, blockquotes, code, links, images, tables, and iframe embeds. Content pasted from Word and Google Docs is cleaned up automatically, and the value submits with the form as an HTML string, so you can handle it on the back end just like a <textarea>.

<quiet-rich-text-editor
  name="post"
  label="Type something rich"
  placeholder="What's on your mind?"
></quiet-rich-text-editor>

Examples Jump to heading

Labels and descriptions Jump to heading

You can use the label and description attributes to provide plain text labels and descriptions for the editor. If you want to provide HTML, use the label and description slots instead.

A few sentences is plenty. See some examples.
<quiet-rich-text-editor name="about" label="About your cat">
  <span slot="description">
    A few sentences is plenty. <a href="https://example.com/" target="_blank">See some examples</a>.
  </span>
</quiet-rich-text-editor>

Providing an initial value Jump to heading

Place HTML inside the element to provide an initial value. You can also use the value attribute, but you'll need to escape the HTML entities if you do.

The editor supports a fixed subset of HTML: paragraphs, headings, blockquotes, code blocks, lists, horizontal rules, tables, images, captioned figures, iframe embeds, and line breaks, plus links, bold, italic, underline, strikethrough, highlight, subscript, superscript, inline code, and text and background colors.

Everything else, including classes, IDs, and inline styles other than text alignment and colors, is stripped when content is loaded or pasted, although the text inside unsupported elements is preserved.

Meet Whiskers

Whiskers is a three-year-old tabby who enjoys:

  • Long naps in direct sunlight
  • Knocking pens off desks
  • Supervising all keyboard activity
<quiet-rich-text-editor name="bio" label="Adoption profile">
  <h2>Meet Whiskers</h2>
  <p>Whiskers is a <strong>three-year-old tabby</strong> who enjoys:</p>
  <ul>
    <li>Long naps in <em>direct sunlight</em></li>
    <li>Knocking pens off desks</li>
    <li>Supervising all keyboard activity</li>
  </ul>
</quiet-rich-text-editor>

Watching the value Jump to heading

The editor's value is always an HTML string. Listen for the quiet-input event to observe it as the user types, or quiet-change to observe it when changes are committed.

const editor = document.querySelector('quiet-rich-text-editor');

editor.addEventListener('quiet-input', () => {
  console.log(editor.value); // "<p>Now with 20% more purring.</p>"
});

Customizing the toolbar Jump to heading

Use the toolbar attribute to choose which formatting controls are available. Provide a space-separated list of tool names, using | to draw a separator between groups. The toolbar stays on a single line and scrolls when it overflows.

<quiet-rich-text-editor
  name="note"
  label="Quick note"
  toolbar="bold italic | link | undo redo"
></quiet-rich-text-editor>

To make the toolbar items wrap instead of scroll, apply flex-wrap: wrap to the component's toolbar part.

The available tools are block, bold, italic, underline, strikethrough, highlight, code, subscript, superscript, color, format-menu, align, lists, link, image, embed, bullet-list, ordered-list, indent, outdent, blockquote, code-block, table, horizontal-rule, clear-formatting, paste-plain-text, undo, and redo.

Four of those are menus that keep the default toolbar compact: block sets the block type, lists covers list types and indenting, align positions text using logical start/end alignments, and format-menu collects the inline formats. The color tool stays a standalone button and never folds into format-menu, since it opens its own menu of swatches. Everything a menu offers is also available as a standalone button, so you can build a flat toolbar with no menus at all. The paste-plain-text tool is a toggle that discards formatting from everything pasted while it's on.

Menus update themselves to avoid duplicates. An item that already has a standalone button elsewhere is dropped from its menu, a menu with one item left renders as a button, and a menu with nothing left disappears. This is why the default toolbar shows bold and italic as buttons while the rest of the inline formats live in format-menu.

Set the attribute to an empty string to remove the toolbar entirely. Keyboard shortcuts and markdown-style input will be unaffected.

<quiet-rich-text-editor 
  name="minimal" 
  label="Minimal editor" 
  toolbar=""
></quiet-rich-text-editor>

Moving the toolbar Jump to heading

The toolbar sits above the editable region by default. Set toolbar-placement="bottom" to move it below, a good fit for chat-style interfaces where the controls stay near the send button. Menus and popovers that open from the toolbar follow along, opening upward to face the content.

<quiet-rich-text-editor
  name="reply"
  label="Reply to Whiskers"
  toolbar-placement="bottom"
></quiet-rich-text-editor>

Markdown shortcuts Jump to heading

Formatting happens as you type when you use familiar markdown patterns. Type #, ##, or ### followed by a space for headings, - or 1. for lists, > for blockquotes, ``` for code blocks, and --- for a horizontal rule. Wrapping text in == highlights it, so ==note== becomes note.

<quiet-rich-text-editor
  label="Try it"
  placeholder="Type # for a heading or wrap text in == to highlight…"
></quiet-rich-text-editor>

Select some text and use the link tool, or press , to add a link. With nothing selected, the link applies to the word under the cursor. Clicking an existing link opens the same editor, where you can change the URL, remove the link, or open it in a new tab. Pasting a URL over selected text also links it.

<quiet-rich-text-editor
  label="Link editing"
  value="<p>Visit the <a href='https://example.com/'>cat cafe</a> to meet adoptable kittens.</p>"
></quiet-rich-text-editor>

To control how authored links open, use the link-target and link-rel attributes. Links targeting _blank automatically get rel="noopener noreferrer" unless link-rel says otherwise.

<quiet-rich-text-editor name="comment" label="Comment" link-target="_blank"></quiet-rich-text-editor>

Links with script-like protocols such as javascript: are stripped on parse and can't be created from the editor. This is defense in depth, not a substitute for sanitizing user-generated HTML on the server.

Providing images Jump to heading

The image tool prompts for a URL and a description. Pasting a URL that points at an image file inserts the image itself rather than the URL as text.

To supply images another way, such as uploading them to your server first, set the imageProvider property to an async function that returns the image's URL, or null to cancel. Pasted and dropped image files are routed through the same function as its file argument. This example uses a file picker and an object URL, but you can show any UI and do any work you need before resolving.

<quiet-rich-text-editor id="rich-text-editor__images" label="Cat photo caption contest" toolbar="bold italic | image"></quiet-rich-text-editor>

<script>
  const editor = document.getElementById('rich-text-editor__images');

  editor.imageProvider = () => {
    return new Promise(resolve => {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      input.addEventListener('change', () => {
        const file = input.files[0];
        resolve(file ? { src: URL.createObjectURL(file), alt: file.name } : null);
      });
      input.addEventListener('cancel', () => resolve(null));
      input.click();
    });
  };
</script>

Once inserted, drag the handle on an image's edge to resize it. As you drag, the width snaps to the image's natural size, half its natural size (the display size of a 2x screenshot), and the editor's full width; hold or to resize freely. Dragging an image out to the size it would render at anyway removes its width attribute entirely, so it stays responsive wherever the content is displayed.

Clicking an image selects it and opens the image editor beside it without interrupting typing focus; the editor follows the image as it resizes and closes when the selection moves on. Press Enter on a selected image to reopen the editor with its URL field focused, or Escape to dismiss it. The editor's delete button removes the image.

The image editor includes a Caption checkbox. Turning it on wraps the image in a <figure> with an editable <figcaption> below it, where you can write inline; turning it off reverts to a plain image.

Resizing writes plain pixel width and height attributes rather than styles, so the sizes survive sanitizers that strip style. Pixel sizes aren't responsive on their own, so give the container you render submitted content in the same guardrail the editor uses:

img, 
iframe { 
  max-width: 100%; 
}

img { 
  height: auto; 
}

Adding tables Jump to heading

Include the table tool in the toolbar to let users insert tables. The toolbar button opens a size picker; while the cursor is inside a table, it opens the table options menu instead. Use Tab and + Tab to move between cells.

While the cursor is in a table, floating controls appear on its edges: an options button at the top-right for row, column, and cell operations, plus quick buttons for appending a row or column. Drag the borders between columns to resize them; widths are stored as data-colwidth attributes. In right-to-left editors, drag-resizing is unavailable and the floating controls mirror to the logical end edge, but every operation remains available through the options menu.

<quiet-rich-text-editor
  label="Feeding schedule"
  toolbar="bold italic | table | undo redo"
  value="<table><tr><th>Cat</th><th>Breakfast</th><th>Dinner</th></tr><tr><td>Whiskers</td><td>7:00</td><td>18:00</td></tr><tr><td>Mochi</td><td>7:15</td><td>18:15</td></tr></table>"
></quiet-rich-text-editor>

Keep the table tool in the toolbar wherever users can insert tables. It becomes the options menu when the cursor is in a table, and it's how keyboard users reach the table operations.

Configuring heading levels Jump to heading

By default, the block menu offers headings 1–3. Use the heading-levels attribute to change which levels are available, providing a space-delimited list of numbers from 1–6. Values outside that range are ignored, and an empty string removes headings entirely.

Headings that arrive by pasting or through an initial value are clamped to the nearest allowed level, so an <h1> becomes an <h2> when level 1 isn't offered. The # shortcut clamps the same way: # starts the smallest allowed heading, and more # than the largest allowed level stays plain text.

Chapter One: The Sunbeam

Every cat knows the warmest spot in the house moves throughout the day. This chapter maps the migration.

Morning positions

Begin at the east-facing windowsill and adjust as needed.

<quiet-rich-text-editor
  label="Field guide"
  heading-levels="2 3 4"
  toolbar="block | bold italic | undo redo"
>
  <h2>Chapter One: The Sunbeam</h2>
  <p>Every cat knows the warmest spot in the house moves throughout the day. This chapter maps the migration.</p>
  <h3>Morning positions</h3>
  <p>Begin at the east-facing windowsill and adjust as needed.</p>
</quiet-rich-text-editor>

Disabling options Jump to heading

Removing a tool from the toolbar only hides its button. The format can still be applied with keyboard and markdown shortcuts or by pasting. To remove a capability completely, list it in the disabled-options attribute.

A disabled option disappears from the toolbar and its menus, its keyboard and markdown shortcuts stop working, and it's stripped from pasted content and initial values. In short, toolbar decides what's shown in the toolbar and disabled-options decides what exists in the markup.

Nothing is silently deleted when a format is stripped. Marks keep their text, so disabling bold turns bold text into plain text, and block formats keep their content, so disabling blockquote unwraps a quote into ordinary paragraphs.

In this example, images, tables, code blocks, and horizontal rules are removed entirely. Try pasting one in.

<quiet-rich-text-editor
  name="restricted"
  label="Comment"
  disabled-options="image table code-block horizontal-rule"
  placeholder="Bold, italic, links, and lists only…"
></quiet-rich-text-editor>

To control which heading levels are available, use heading-levels rather than disabled-options.

Customizing colors Jump to heading

The color tool opens a menu with a row of text colors and a row of background colors. Text colors are applied to a <span>; background colors are applied to a <mark>, the same element the highlight tool uses, so a highlight and a background color are one and the same. The default palettes adapt to light and dark mode on their own, so colored content looks right in both without any extra work.

To offer your own colors, set the text-colors and background-colors attributes to a semicolon-delimited list of CSS colors. Setting either to an empty string removes that row from the menu.

Meet Marmalade, our longest napper on record.

<quiet-rich-text-editor
  name="flyer"
  label="Adoption flyer"
  text-colors="light-dark(#914b06, #e4b073); light-dark(#2f57bc, #98bafe)"
  background-colors="#dcb31e4d; #4a97f44d"
>
  <p>Meet <span style="color: light-dark(#914b06, #e4b073);">Marmalade</span>, our <mark style="background-color: #dcb31e4d;">longest napper</mark> on record.</p>
</quiet-rich-text-editor>

Colors are written to the value exactly as you provide them. Use light-dark() values or translucent colors, like the defaults do, so your content stays legible when the color scheme changes.

A <mark> with no color of its own, such as one the highlight tool creates, is yellow, matching the yellow swatch in the menu. Choosing that swatch produces a plain <mark> rather than an inline style, since the editor already paints it that color.

For better screen reader labels, assign an array of ColorSwatch objects to the textColors and backgroundColors properties instead. For example:

editor.textColors = [
  { color: '#914b06', label: 'Orange' },
  { color: '#316923', label: 'Green' },
  { color: '#2f57bc', label: 'Blue' }
];

The default palettes come with localized labels already.

Suggesting completions Jump to heading

Set the suggestions property to enable typeahead menus while the user types, such as @ mentions. Each source provides a trigger character and a getItems(query) callback that returns items for whatever was typed after the trigger. Return a promise to fetch them from your server. Use the arrow keys to navigate the menu and Enter or Tab to choose.

When an item has a value, choosing it inserts a mention that serializes as <span data-mention="value">label</span>, so you can resolve who was mentioned on the back end. Mentions behave as a single unit in the editor.

<quiet-rich-text-editor
  id="rich-text-editor__mentions"
  label="Comment"
  placeholder="Type @ to mention a cat…"
></quiet-rich-text-editor>

<script>
  const editor = document.getElementById('rich-text-editor__mentions');
  const cats = [
    { value: '1', label: 'Whiskers', description: 'Tabby' },
    { value: '2', label: 'Mochi', description: 'Ragdoll' },
    { value: '3', label: 'Purrlock', description: 'Shorthair' },
    { value: '4', label: 'Noodleton', description: 'Sphynx' }
  ];

  editor.suggestions = [
    {
      trigger: '@',
      getItems: query => cats.filter(cat => cat.label.toLowerCase().startsWith(query.toLowerCase()))
    }
  ];
</script>

Items without a value insert plain text, which works well for emoji and snippet completions. You can register multiple sources with different triggers on the same editor.

<quiet-rich-text-editor id="editor" label="Status update" placeholder="Type : followed by a name, like :cat…"></quiet-rich-text-editor>

<script>
  const editor = document.getElementById('editor');
  const emoji = [
    { label: '🐱', description: ':cat:' },
    { label: '😻', description: ':heart-eyes-cat:' },
    { label: '🙀', description: ':scream-cat:' },
    { label: '🐾', description: ':paw-prints:' }
  ];

  editor.suggestions = [
    {
      trigger: ':',
      getItems: query => emoji.filter(item => item.description.includes(query.toLowerCase()))
    }
  ];
</script>

Detecting changes Jump to heading

The editor tracks whether its value differs from its default value, which is captured when it first renders. Use the dirty property and matching :state(dirty) custom state to warn about unsaved changes or to build autosave flows.

After saving, set defaultValue to the current value to mark the editor as clean. Programmatic value changes don't reset the user's undo history, so it's safe to write the value back after saving.

const editor = document.querySelector('quiet-rich-text-editor');
const saveButton = document.querySelector('#save-button');

editor.addEventListener('quiet-input', () => (saveButton.disabled = !editor.dirty));

saveButton.addEventListener('click', async () => {
  await save(editor.value);
  editor.defaultValue = editor.value; // the editor is clean again
  saveButton.disabled = true;
});

Controlling the editor with JavaScript Jump to heading

The editor exposes an imperative API so you can drive it from your own code, such as custom buttons or keyboard handlers, without reaching into ProseMirror.

Run a formatting command by name with runCommand(name, options?). It returns true when the command applied. Some commands take options, such as runCommand('heading', { level: 2 }) or runCommand('link', { href }). The table-* commands apply only when the selection is inside a table.

Command Options Description
bold
italic
underline
strikethrough
highlight
code
subscript
superscript
Toggles the inline mark.
color { color?, background? } Sets the text color and/or the background color, the latter as a highlight. Pass null to remove a color; omit a field to keep it.
link { href, target?, rel? } Applies a link to the selection. target and rel default to the link-target and link-rel attributes.
unlink Removes the link at the selection.
paragraph
code-block
Sets the block format.
heading { level } Applies a heading of the given level. heading-1heading-6 also work as names.
blockquote Toggles a blockquote.
align { to } Aligns the block to start, center, end, or justify.
bullet-list
ordered-list
Toggles a list.
indent
outdent
Indents or outdents the current list item.
image { src, alt?, caption? } Inserts an image, or updates the selected one.
embed { src, title? } Inserts an iframe embed, or updates the selected one. Only absolute http(s) URLs are accepted.
table { rows?, columns? } Inserts a table.
horizontal-rule Inserts a horizontal rule.
table-add-row-above
table-add-row-below
Adds a row above or below the current one.
table-add-column-before
table-add-column-after
Adds a column before or after the current one.
table-delete-row
table-delete-column
Deletes the current row or column.
table-delete Deletes the entire table.
table-move-row-up
table-move-row-down
Moves the current row up or down.
table-move-column-before
table-move-column-after
Moves the current column left or right.
table-merge-cells
table-split-cell
Merges the selected cells, or splits a merged cell.
table-select-cell
table-select
Selects the current cell or the entire table.
table-toggle-header-row
table-toggle-header-column
Toggles the header row or header column.
clear-formatting Removes marks from the selection.
undo
redo
Steps through the edit history.

Beyond commands, these methods and properties are available:

  • insertHtml(html) inserts an HTML fragment at the cursor, replacing the selection. Anything the schema or your disabled options don't allow is stripped, so this is the way to insert arbitrary supported markup, e.g. insertHtml('<br>').
  • insertText(text) inserts plain text at the cursor.
  • deleteSelection() removes the current selection.
  • selectAll() selects all content.
  • undo() / redo() and the read-only canUndo / canRedo properties.
  • isActive(name) reports whether a format is active at the selection, e.g. isActive('bold'), isActive('heading-2'), isActive('align-center'), isActive('color'), isActive('link'), isActive('image'), isActive('embed'), or isActive('table').
  • The read-only selectedText property and getSelection() for the current selection.
  • The editor property returns the underlying ProseMirror EditorView for advanced use.

Listen for quiet-selection-change to keep custom UI in sync. Unlike quiet-input, it also fires when the cursor moves without editing.

Bold Insert paw
<quiet-rich-text-editor id="rich-text-editor__api" label="Field notes" value="<p>The cat sat on the mat.</p>" style="margin-block-end: 1.5rem;"></quiet-rich-text-editor>

<quiet-button id="rich-text-editor__api-bold">Bold</quiet-button>
<quiet-button id="rich-text-editor__api-paw">Insert paw</quiet-button>

<script>
  const editor = document.getElementById('rich-text-editor__api');
  const boldButton = document.getElementById('rich-text-editor__api-bold');
  const pawButton = document.getElementById('rich-text-editor__api-paw');

  boldButton.addEventListener('click', () => {
    editor.focus();
    editor.runCommand('bold');
  });

  pawButton.addEventListener('click', () => {
    editor.focus();
    editor.insertText(' 🐾');
  });

  // Reflect the active state on the Bold button as the selection moves
  editor.addEventListener('quiet-selection-change', () => {
    boldButton.toggle = editor.isActive('bold') ? 'on' : 'off';
  });
</script>

Adding custom toolbar buttons Jump to heading

Put your own buttons in the actions slot to add them to the toolbar, then wire them up with the imperative API above. Slotted buttons appear at the end of the toolbar by default. To place them somewhere else, add actions to the toolbar attribute wherever you want them to appear, e.g. toolbar="actions | bold italic".

<quiet-rich-text-editor
  id="rich-text-editor__actions"
  label="Status update"
  toolbar="actions | bold italic"
  value="<p>All cats accounted for.</p>"
>
  <quiet-button slot="actions" id="rich-text-editor__actions-stamp" appearance="text" icon-label="Insert timestamp">
    <quiet-icon name="clock"></quiet-icon>
  </quiet-button>
</quiet-rich-text-editor>

<script>
  const editor = document.getElementById('rich-text-editor__actions');
  const stamp = document.getElementById('rich-text-editor__actions-stamp');

  stamp.addEventListener('click', () => {
    editor.focus();
    editor.insertText(`[${new Date().toLocaleTimeString()}] `);
  });
</script>

Embedding external content Jump to heading

The embed tool inserts external pages, such as videos, maps, and audio players, as <iframe> elements. It's not in the default toolbar, so add it where it makes sense.

In the prompt, paste a page URL or the full embed code a site hands out. URLs from recognized providers, including YouTube, Vimeo, Spotify, SoundCloud, Dailymotion, Loom, and Wistia, convert to their embeddable sources automatically, and embed codes are reduced to just the iframe's URL, description, and size. Only absolute http(s) URLs are accepted, and embeds always serialize with the same fixed attributes: src, title, width, height, allowfullscreen, and loading="lazy". The description becomes the iframe's title, which is how screen readers announce the frame.

While editing, embeds behave like images: click to select, drag the handle to resize, press Enter to reopen the editor, and Escape to dismiss it. Without an explicit size, an embed fills the editor's width at a 16:9 aspect ratio, and the embedded page stays inert until the content is rendered elsewhere.

Jurassic Park but with a Cat!

<quiet-rich-text-editor id="rich-text-editor__embeds" label="Cat video reviews" toolbar="bold italic | embed">
  <p>Jurassic Park but with a Cat!</p>
  <iframe src="https://www.youtube.com/embed/W85oD8FEF78?si=U1Syihbvimz2VL83" title="Keyboard Cat playing a jaunty tune"></iframe>
</quiet-rich-text-editor>

To support more providers or change how URLs resolve, set the embedProvider property to an async function. It receives the raw field text and returns the embed's src, an object with src and optional title, width, and height, null to reject the input, or undefined to fall back to the built-in resolver. Being async, it can call an oEmbed endpoint or your own backend for providers whose embeds can't be derived from the URL alone, such as Twitter/X, Instagram, and CodePen.

<script type="module">
  const editor = document.querySelector('#my-rich-text');

  editor.embedProvider = async raw => {
    // Add a provider the editor doesn't know about
    const match = raw.match(/^https:\/\/example\.com\/v\/(\w+)$/);
    if (match) {
      return { src: `https://example.com/embed/${match[1]}`, title: 'Example video' };
    }

    // Fall back to the built-in resolver for everything else
    return undefined;
  };
</script>

Script-based embed codes, such as the <blockquote> + <script> snippets some social networks provide, aren't supported because the editor doesn't accept scripts and arbitrary HTML.

Handling embedded images Jump to heading

Without an image provider, pasted and dropped image files are embedded directly in the value as data: URLs so the content survives on its own. They don't count toward the character count, but they make the submitted value roughly a third larger than the image files themselves, so make sure your server accepts payloads of that size.

For production apps that expect image pastes, setting imageProvider so files upload instead is almost always the better fit. Because embedded images inflate the value, avoid pairing this behavior with maxlength.

If you do accept embedded images, you'll want to extract them on the server after submission. To do this, decode each data: URL back into a file, store it, and swap the stored file's URL into the HTML. For example, in Node.js:

/** Replaces embedded images in submitted HTML with stored files. Run this before sanitizing and saving. */
async function extractEmbeddedImages(html) {
  const embeds = [...html.matchAll(/"data:(image\/[a-z0-9.+-]+);base64,([^"]+)"/gi)];

  for (const [match, mimeType, base64] of embeds) {
    const buffer = Buffer.from(base64, 'base64'); // the original file's bytes
    const url = await saveImage(buffer, mimeType); // store it however you like and return its URL
    html = html.replace(match, JSON.stringify(url));
  }

  return html;
}

Styling the content Jump to heading

The editor renders its content inside a shadow root, so the page's stylesheet can't reach elements like headings, links, and tables. To restyle them, slot in a <style slot="content-styles"> element. Its CSS is injected into the editor's shadow root, so nest your rules under [part="editor"] to target the content and leave the toolbar, menus, and popovers alone. The built-in typography lives in a low-priority cascade layer, so your selectors win without fighting specificity.

Use adaptive colors such as --quiet-primary-fill-softer in your CSS and the content will adapt to light and dark mode automatically, as shown below.

The Naptime Manifesto

Every self-respecting cat knows that a day without a dozen naps is a day squandered. Consider this your field guide to doing absolutely nothing, gloriously.

The Golden Rules

If it fits, I sits. If it doesn't fit, I sits anyway.

Recommended Nap Spots

SpotComfortSunlight
WindowsillHighExcellent
Laundry basketSupremeNone
A warm keyboardMediumCozy
<quiet-rich-text-editor label="Editorial" id="rich-text-editor__styled">
  <style slot="content-styles">
    [part='editor'] {
      h1,
      h2 {
        font-family: Georgia, 'Times New Roman', serif;
        letter-spacing: -0.02em;
      }

      h2 {
        padding-block-end: 0.15em;
        border-block-end: dashed 2px var(--quiet-primary-stroke-soft);
      }

      /* A drop cap for the opening paragraph */
      h1 + p::first-letter {
        float: inline-start;
        margin-inline: 0.05em 0.1em;
        color: var(--quiet-primary-text-colorful);
        font: 700 3.1em/0.75 Georgia, serif;
      }

      a {
        color: var(--quiet-primary-text-colorful);
        text-decoration-thickness: 2px;
        text-underline-offset: 3px;
      }

      blockquote {
        position: relative;
        margin-inline: 0;
        padding: 0.75em 1em 0.75em 2.25em;
        border: none;
        border-radius: var(--quiet-border-radius-md);
        background: var(--quiet-primary-fill-softer);
        color: var(--quiet-primary-text-on-soft);
        font-style: italic;
      }

      blockquote::before {
        content: '\201C';
        position: absolute;
        inset-block-start: -0.1em;
        inset-inline-start: 0.15em;
        color: var(--quiet-primary-stroke-soft);
        font: 700 2.5em Georgia, serif;
      }

      table th {
        background: var(--quiet-primary-fill-mid);
        color: var(--quiet-primary-text-on-mid);
      }

      table tr:nth-child(even) td {
        background: var(--quiet-neutral-fill-softer);
      }
    }
  </style>
  <h1>The Naptime Manifesto</h1>
  <p>Every self-respecting cat knows that a day without a dozen naps is a day squandered. Consider this your field guide to doing absolutely nothing, gloriously.</p>
  <h2>The Golden Rules</h2>
  <blockquote>If it fits, I sits. If it doesn't fit, I sits anyway.</blockquote>
  <ul>
    <li>Sunbeams are non-negotiable.</li>
    <li>The warmest lap always wins.</li>
    <li>Keyboards are for sitting, per <a href="https://example.com/">the official field notes</a>.</li>
  </ul>
  <h2>Recommended Nap Spots</h2>
  <table>
    <tr><th>Spot</th><th>Comfort</th><th>Sunlight</th></tr>
    <tr><td>Windowsill</td><td>High</td><td>Excellent</td></tr>
    <tr><td>Laundry basket</td><td>Supreme</td><td>None</td></tr>
    <tr><td>A warm keyboard</td><td>Medium</td><td>Cozy</td></tr>
  </table>
</quiet-rich-text-editor>

These styles reach the entire shadow root, not just the content. Rules that aren't nested under [part="editor"] can bleed into the editor's own toolbar, menus, and popovers, so keep your selectors scoped to the content region.

Changing the content height Jump to heading

By default, the editable region grows as content is added with no upper limit. Use the --content-min-height custom property to change the starting height.

<quiet-rich-text-editor label="Short note" style="--content-min-height: 4em;"></quiet-rich-text-editor>

To cap how tall the editor can grow, set --content-max-height. Once the content exceeds it, the editable region scrolls while the toolbar stays pinned.

<quiet-rich-text-editor label="Capped height" style="--content-max-height: 12em;"></quiet-rich-text-editor>

Changing the appearance Jump to heading

Set the appearance attribute to normal, filled, or unstyled to change the editor's appearance.

<quiet-rich-text-editor appearance="normal" label="Normal" style="margin-block-end: 1.5rem;"></quiet-rich-text-editor>
<quiet-rich-text-editor appearance="filled" label="Filled" style="margin-block-end: 1.5rem;"></quiet-rich-text-editor>
<quiet-rich-text-editor appearance="unstyled" label="Unstyled"></quiet-rich-text-editor>

Changing the size Jump to heading

Use the size attribute to change the editor's size. The toolbar and content scale with it.

<quiet-select label="Select a size" value="xs" style="max-width: 18rem; margin-block-end: 2rem;">
  <option value="xs">Extra small</option>
  <option value="sm">Small</option>
  <option value="md">Medium</option>
  <option value="lg">Large</option>
  <option value="xl">Extra large</option>
</quiet-select>

<quiet-rich-text-editor
  size="xs"
  label="Rich text sizes"
  id="rich-text-editor__size"
></quiet-rich-text-editor>

<script>
  const richText = document.getElementById('rich-text-editor__size');
  const select = richText.previousElementSibling;

  select.addEventListener('quiet-change', () => {
    richText.size = select.value;
  });
</script>

Showing a character count Jump to heading

Add the with-count attribute to show a character count below the editor. When combined with maxlength, the count shows remaining characters instead. Unlike native inputs, typing isn't blocked at the limit; the editor becomes invalid instead, which plays nicer with pasting and undo.


Submit
<form action="about:blank" method="get" target="_blank">
  <quiet-rich-text-editor name="summary" label="Summary" with-count maxlength="100"></quiet-rich-text-editor>
  <br>
  <quiet-button type="submit" variant="primary">Submit</quiet-button>
</form>

Embedded data: images don't count toward the character count, so a document can exceed maxlength in bytes while staying under the limit in characters.

Disabling Jump to heading

Use the disabled attribute to disable the editor.

Napping in progress. Do not disturb.

<quiet-rich-text-editor label="Disabled" disabled>
  <p>Napping in progress. Do not disturb.</p>
</quiet-rich-text-editor>

Read-only Jump to heading

Use the readonly attribute to make the editor read-only, keeping the content selectable.

The catnip policy is final and not open for edits.

<quiet-rich-text-editor label="Read-only" readonly>
  <p>The catnip policy is final and not open for edits.</p>
</quiet-rich-text-editor>

Validation Jump to heading

The required and maxlength attributes can be used to enable validation using the Constraint Validation API . A required editor prevents the form from submitting while it's blank.


Submit Reset
<form action="about:blank" method="get" target="_blank">
  <quiet-rich-text-editor name="reason" label="Why does your cat deserve a treat?" required></quiet-rich-text-editor>
  <br>
  <quiet-button type="submit" variant="primary">Submit</quiet-button>
  <quiet-button type="reset">Reset</quiet-button>
</form>

Using custom validation Jump to heading

Use the setCustomValidity() method to make the editor invalid and show a custom error message on submit. This will override all other validation parameters. To clear the error, call the method with an empty string.


Submit
<form action="about:blank" method="get" target="_blank" id="rich-text-editor__custom-validation">
  <quiet-rich-text-editor 
    name="feedback"
    label="Feedback"
    description="This field will be invalid until custom validation is removed"
  ></quiet-rich-text-editor>
  <br>
  <quiet-button type="submit" variant="primary">Submit</quiet-button>
</form>

<script type="module">
  import { allDefined } from '/dist/quiet.js';

  await allDefined();

  const form = document.getElementById('rich-text-editor__custom-validation');
  const richText = form.querySelector('quiet-rich-text-editor');

  richText.setCustomValidity('Not so fast, bubba!');
</script>

Styling validation Jump to heading

You can style valid and invalid editors using the user-valid and user-invalid custom states. These styles are only shown after the user interacts with the form control or when the form is submitted. The :valid and :invalid pseudo classes are also available, but they match even before the user has had a chance to fill out the form.


Submit Reset
<form action="about:blank" method="get" target="_blank" class="rich-text-editor__validation-custom">
  <quiet-rich-text-editor 
    name="reason"
    label="Why does your cat deserve a treat?"
    description="This field is required"
    required
  ></quiet-rich-text-editor>
  <br>
  <quiet-button type="submit" variant="primary">Submit</quiet-button>
  <quiet-button type="reset">Reset</quiet-button>
</form>

<style>
  .rich-text-editor__validation-custom {
    quiet-rich-text-editor:state(user-valid) {
      outline: solid 2px var(--quiet-constructive-stroke-mid);
      outline-offset: .5rem;
    }

    quiet-rich-text-editor:state(user-invalid) {
      outline: solid 2px var(--quiet-destructive-stroke-mid);
      outline-offset: .5rem;
    }
  }
</style>

If you're using the CSS utilities, add the quiet-user-valid and quiet-user-invalid classes to any form control for automatic validation styling.

Keyboard support Jump to heading

Tab to the editor to focus it, then use the keys below. The toolbar is a single tab stop — use the arrow keys to move between its buttons.

Key Action
Toggles bold
Toggles italic
Toggles underline
Toggles strikethrough
Toggles inline code
Toggles highlight
/ Toggles subscript and superscript
Opens the link editor
Applies heading 1–3
Reverts the block to a paragraph
Toggles a numbered list
Toggles a bulleted list
Toggles a blockquote
Tab / + Tab Moves between table cells and indents or outdents list items; moves focus elsewhere
+ Enter Inserts a line break
/ Grows and shrinks the selected image or embed
(or ) while resizing Temporarily disables snapping while dragging an image or embed's resize handle
Enter Opens the editor for the selected image or embed; chooses the highlighted suggestion; leaves a figure caption
Escape Dismisses the link, image, or embed editor and returns focus to the content
Moves through open suggestion menus
Undoes the last change
Redoes the last undone change

API Jump to heading

Importing Jump to heading

The autoloader is the recommended way to import components but, if you prefer to do it manually, the following code snippets will be helpful.

CDN Self-hosted

To manually import <quiet-rich-text-editor> from the CDN, use the following code.

import 'https://cdn.quietui.org/v6.0.0/components/rich-text-editor/rich-text-editor.js';

To manually import <quiet-rich-text-editor> from a self-hosted distribution, use the following code. Remember to replace /path/to/quiet with the appropriate local path.

import '/path/to/quiet/components/rich-text-editor/rich-text-editor.js';

Slots Jump to heading

Rich Text Editor supports the following slots. Learn more about using slots

Name Description
label The editor's label. For plain-text labels, you can use the label attribute instead.
description The editor's description. For plain-text descriptions, you can use the description attribute instead.
content-styles A <style> element whose CSS is applied to the editor's content, letting you fully restyle headings, links, tables, and more. The styles are injected into the editor's shadow root and reach the whole editor, so nest your rules under [part="editor"] to keep them from affecting the toolbar, menus, and popovers.
actions Custom buttons to add to the toolbar, e.g. to run editor commands via the imperative API. Buttons render right after the tools by default; add the actions keyword to the toolbar attribute to position them elsewhere. Use buttons here, not focusable inputs.

Properties Jump to heading

Rich Text Editor has the following properties that can be set with corresponding attributes. In many cases, the attribute's name is the same as the property's name. If an attribute is different, it will be displayed after the property. Learn more about attributes and properties

Property Description Reflects Type Default
label The editor's label. If you need to provide HTML in the label, use the label slot instead. string
description The editor's description. If you need to provide HTML in the description, use the description slot instead. string
name The name of the editor. This will be submitted with the form as a name/value pair. string
value The editor's value as an HTML string. To provide an initial value, use the value attribute or place the HTML directly inside the element. string ''
defaultValue The editor's default value, captured from the initial value when the editor first renders. The form's reset behavior restores this value, and the dirty state compares against it. Set it to the current value after saving to mark the editor as clean, e.g. for autosave flows. Property only. string ''
placeholder A placeholder to show in the editor when it's blank. string
disabled Disables the editor. boolean false
readonly Makes the editor read-only. The toolbar will remain visible, but its controls will be disabled. boolean false
appearance The type of editor to render. 'normal' | 'filled' | 'unstyled' 'normal'
size The editor's size. 'xs' | 'sm' | 'md' | 'lg' | 'xl' 'md'
toolbar The formatting controls to show in the toolbar. Provide a space-separated list of tool names, using | to draw a separator between groups; groups wrap as a unit when the toolbar overflows. Available tools: block, bold, italic, underline, strikethrough, highlight, code, subscript, superscript, color, format-menu, align, link, image, embed, lists, bullet-list, ordered-list, indent, outdent, blockquote, code-block, table, horizontal-rule, clear-formatting, paste-plain-text, undo, and redo. The special actions token marks where custom buttons from the actions slot appear; without it, they render after the tools. Set this to an empty string to remove the toolbar entirely. string 'block | bold italic color format-menu | lists align | link image table | undo redo'
toolbarPlacement
toolbar-placement
The placement of the toolbar relative to the editable region. 'top' | 'bottom' 'top'
disabledOptions
disabled-options
Removes formatting options from the editor entirely, as a space-delimited list. A disabled option vanishes from the toolbar and its keyboard and markdown shortcuts stop working, and it's stripped from pasted content and initial values, so it can never enter the document. Marks keep their text when stripped; block formats keep their content. Accepts the same tokens as toolbar (bold, italic, underline, strikethrough, highlight, code, subscript, superscript, color, link, image, embed, table, blockquote, code-block, bullet-list, ordered-list, horizontal-rule, align) plus the alias lists for both list types. To control which heading levels are available, use heading-levels instead. string ''
textColors
text-colors
The text colors offered in the toolbar's color menu. Accepts a semicolon-delimited string of CSS colors, e.g. #ff0000; #00ff00, or an array of ColorSwatch objects with color and optional label properties for improved accessibility. The default palette uses light-dark() values that adapt to light and dark mode. Set this to an empty string to remove the text color row from the menu. string | ColorSwatch[]
backgroundColors
background-colors
The background colors offered in the toolbar's color menu, in the same formats as text-colors. These are applied as highlights, so the selection is wrapped in a <mark>. The default palette uses translucent tints that work in both light and dark mode. Set this to an empty string to remove the background color row from the menu. string | ColorSwatch[]
headingLevels
heading-levels
The heading levels available in the editor's text styles menu, as a space-delimited list of numbers from 1–6, e.g. "2 3 4". Values outside that range are ignored, and an empty string removes headings entirely. Headings that arrive from pasting or an initial value are clamped to the nearest allowed level. string '1 2 3'
form The form to associate this control with. If omitted, the closest containing <form> will be used. The value of this attribute must be an ID of a form in the same document or shadow root. string
required Makes the editor required. Form submission will not be allowed when this is set and the editor is blank. boolean false
linkTarget
link-target
When set, links created in the editor get this target, e.g. _blank to open in a new tab. Links opened in a new tab automatically get rel="noopener noreferrer" unless link-rel says otherwise. string
linkRel
link-rel
When set, links created in the editor get this rel attribute. string
maxLength
maxlength
The maximum number of text characters that will be considered valid. Block boundaries count as one character, like line breaks in a text area. Unlike native inputs, typing isn't blocked when the limit is exceeded; the editor becomes invalid instead. number
withCount
with-count
Shows a character count below the editor. When maxlength is set, shows remaining characters. boolean false
autofocus Tells the browser to focus the editor when the page loads or a dialog is shown. boolean
suggestions Completion sources that open a suggestion menu while typing, such as @ mentions or : emoji shortcodes. Each source provides a trigger character and a getItems(query) callback. Items with a value insert a mention that serializes as <span data-mention="value">label</span>; items without one insert plain text. Property only. RichTextSuggestionSource[] []
imageProvider A function that supplies images to the editor. When set, activating the image tool calls this function instead of prompting for a URL, allowing you to show a file picker, upload the image to your server, and return its final URL. Pasted and dropped image files are also routed through it, passed as the file argument. Return a string, an object with src and optional alt properties, or null to cancel. Without a provider, pasted and dropped image files are embedded as data: URLs. Property only. ((file?: File) => Promise<RichTextImage | null | undefined>) | undefined
embedProvider A function that resolves embed input into a final embed. It receives the raw text from the embed field — a plain URL or a full <iframe> snippet — and returns the embed src, an object with src and optional title, width, and height, or null to reject the input. Return undefined to fall back to the built-in resolver, which handles iframe snippets and common providers such as YouTube, Vimeo, Spotify, and SoundCloud. Because it's async, you can call an oEmbed endpoint or your own backend to support additional providers. Property only. ((raw: string) => Promise<RichTextEmbed | null | undefined>) | undefined
editor The ProseMirror EditorView that powers the editor, for advanced use cases such as dispatching custom transactions. This is undefined until the component has rendered. (Advanced) EditorView | undefined
textValue The editor's content as plain text, with block boundaries rendered as newlines. Useful for character counting and search. Read-only. string
dirty Determines whether the editor's value currently differs from its default value. Read-only. boolean
selectedText The plain text within the current selection, with block boundaries rendered as newlines. Read-only. string
canUndo Whether an edit can currently be undone. Read-only. boolean
canRedo Whether an edit can currently be redone. Read-only. boolean

Methods Jump to heading

Rich Text Editor supports the following methods. You can obtain a reference to the element and call them like functions in JavaScript. Learn more about methods

Name Description Arguments
runCommand() Runs a formatting command by name, such as bold, heading, or table-add-row-below. Some commands take options, e.g. runCommand('heading', { level: 2 }) or runCommand('link', { href }). Returns whether the command applied; an unknown or inapplicable command returns false without throwing. Commands for formats removed by disabled-options or heading-levels don't apply. See the docs for the full list of commands. name: string, options: RichTextCommandOptions
insertHtml() Inserts an HTML fragment at the current selection, replacing it. Anything the schema doesn't support, or that the editor's disabled options forbid, is stripped. Use this for arbitrary supported markup, e.g. insertHtml('<br>'). html: string
insertText() Inserts plain text at the current selection, replacing it. text: string
deleteSelection() Deletes the current selection. Does nothing when the selection is empty.
selectAll() Selects all of the editor's content.
undo() Undoes the last edit, if any. Returns whether an edit was undone.
redo() Redoes the last undone edit, if any. Returns whether an edit was redone.
getSelection() Gets the current selection as document positions, for advanced use alongside the editor property. The positions are ProseMirror document offsets, not plain-text offsets. Read-only.
isActive() Determines whether a format or context is active at the current selection, such as bold, heading-2, bullet-list, align-center, link, image, or table. Useful for reflecting state in a custom toolbar. name: string
focus() Sets focus to the editor. Pass { focusVisible: false } to keep the focus ring hidden, e.g. for pointer input. options: FocusOptions
blur() Removes focus from the editor.
checkValidity() Checks if the form control has any restraints and whether it satisfies them. If invalid, false will be returned and the invalid event will be dispatched. If valid, true will be returned.
reportValidity() Checks if the form control has any restraints and whether it satisfies them. If invalid, false will be returned and the invalid event will be dispatched. In addition, the problem will be reported to the user. If valid, true will be returned.
setCustomValidity() Sets a custom validation message for the form control. If this message is not an empty string, then the form control is considered invalid and the specified message will be displayed to the user when reporting validity. Setting an empty string clears the custom validity state. message: string

Events Jump to heading

Rich Text Editor dispatches the following custom events. You can listen to them the same way was native events. Learn more about custom events

Name Description
quiet-blur Emitted when the editor loses focus. This event does not bubble.
quiet-change Emitted when the user commits changes to the editor's value.
quiet-focus Emitted when the editor receives focus. This event does not bubble.
quiet-input Emitted when the editor receives input.
quiet-selection-change Emitted when the selection or active formatting changes, including on cursor moves that don't edit the document. Useful for keeping custom toolbar buttons in sync with isActive().

CSS custom properties Jump to heading

Rich Text Editor supports the following CSS custom properties. You can style them like any other CSS property. Learn more about CSS custom properties

Name Description Default
--content-min-height The minimum height of the editable region. 8em
--content-max-height The maximum height of the editable region. When content exceeds this, the editable region scrolls while the toolbar stays pinned. Defaults to none, so the editor grows with its content. none
--content-root-font-size The root font size for the editable region. All content spacing and typography scale from this, so changing it resizes the content as a whole. 1em
--popover-show-duration How long the link, image, and embed popovers take to animate in. Set to 0ms to show them instantly. 50ms
--popover-hide-duration How long the link, image, and embed popovers take to animate out. Set to 0ms to hide them instantly. 50ms
--selection-outline-width The width of the outline drawn around selected nodes such as images, embeds, figures, and rules. 2px
--selection-outline-color The color of the outline drawn around selected nodes such as images, embeds, figures, and rules. var(--quiet-focus-color)
--cell-selection-border-width The width of the accent border drawn on the grid lines of selected table cells. 1px
--cell-selection-border-color The color of the accent border drawn on the grid lines of selected table cells. var(--quiet-primary-stroke-soft)
--cell-selection-background-color The background color drawn over selected table cells. Defaults to a translucent mix of the text selection color.

CSS parts Jump to heading

Rich Text Editor exposes internal elements that can be styled with CSS using the selectors shown below. Learn more about CSS parts

Name Description CSS selector
label The element that contains the editor's label. ::part(label)
description The element that contains the editor's description. ::part(description)
visual-box The element that wraps the toolbar and the editable region. ::part(visual-box)
toolbar-scroller The <quiet-scroller> that lets the toolbar scroll horizontally when it overflows. To make the toolbar wrap instead, use ::part(toolbar) { flex-wrap: wrap; }. ::part(toolbar-scroller)
toolbar The toolbar that contains the formatting controls, a <quiet-toolbar> element. ::part(toolbar)
editor The editable region of the editor. ::part(editor)
count The character count element, rendered when the with-count attribute is present. ::part(count)
suggestion-menu The suggestion menu's scrollable list, a role="listbox" element. ::part(suggestion-menu)
suggestion-item Each item in the suggestion menu, a role="option" button. ::part(suggestion-item)
suggestion-item-active The currently highlighted item in the suggestion menu. ::part(suggestion-item-active)
suggestion-label The label shown for each suggestion. ::part(suggestion-label)
suggestion-description The optional description shown for each suggestion. ::part(suggestion-description)

Custom States Jump to heading

Rich Text Editor has the following custom states. You can target them with CSS using the selectors shown below. Learn more about custom states

Name Description CSS selector
disabled Applied when the editor is disabled. :state(disabled)
blank Applied when the editor has a blank value. :state(blank)
dirty Applied when the editor's value differs from its default value. :state(dirty)
focused Applied when the editor has focus. :state(focused)
user-valid Applied when the editor is valid and the user has sufficiently interacted with it. :state(user-valid)
user-invalid Applied when the editor is invalid and the user has sufficiently interacted with it. :state(user-invalid)

Dependencies Jump to heading

Rich Text Editor automatically imports the following elements. Sub-dependencies are also included in this list.

Search this website Toggle dark mode View the code on GitHub Follow @quietui.org on Bluesky Follow @quiet_ui on X

    No results found