Rich Text Editor
<quiet-rich-text-editor>
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.
<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>
Working with links Jump to heading
Select some text and use the link tool, or press
<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
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
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 |
|---|---|---|
bolditalicunderlinestrikethroughhighlightcodesubscriptsuperscript
|
— | 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. |
paragraphcode-block |
— | Sets the block format. |
heading |
{ level } |
Applies a heading of the given level. heading-1 … heading-6 also work as
names.
|
blockquote |
— | Toggles a blockquote. |
align |
{ to } |
Aligns the block to start, center, end, or
justify.
|
bullet-listordered-list |
— | Toggles a list. |
indentoutdent |
— | 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-abovetable-add-row-below |
— | Adds a row above or below the current one. |
table-add-column-beforetable-add-column-after |
— | Adds a column before or after the current one. |
table-delete-rowtable-delete-column |
— | Deletes the current row or column. |
table-delete |
— | Deletes the entire table. |
table-move-row-uptable-move-row-down |
— | Moves the current row up or down. |
table-move-column-beforetable-move-column-after |
— | Moves the current column left or right. |
table-merge-cellstable-split-cell |
— | Merges the selected cells, or splits a merged cell. |
table-select-celltable-select |
— | Selects the current cell or the entire table. |
table-toggle-header-rowtable-toggle-header-column |
— | Toggles the header row or header column. |
clear-formatting |
— | Removes marks from the selection. |
undoredo |
— | 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-onlycanUndo/canRedoproperties. -
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'), orisActive('table'). -
The read-only
selectedTextproperty andgetSelection()for the current selection. -
The
editorproperty returns the underlying ProseMirrorEditorViewfor 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.
<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
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.
- Sunbeams are non-negotiable.
- The warmest lap always wins.
- Keyboards are for sitting, per the official field notes.
Recommended Nap Spots
| Spot | Comfort | Sunlight |
|---|---|---|
| Windowsill | High | Excellent |
| Laundry basket | Supreme | None |
| A warm keyboard | Medium | Cozy |
<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.
<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
. A required editor prevents the form from submitting while it's blank.
<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.
<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.
<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 / |
Moves between table cells and indents or outdents list items; moves focus elsewhere |
| Inserts a line break | |
| Grows and shrinks the selected image or embed | |
|
|
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.
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
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.