RichTextEditor
A small WYSIWYG editor — a formatting toolbar over a contenteditable region, synced to a hidden field for form submission.
Last updated
Uses JavaScript
Wires each toolbar button to document.execCommand, syncs the editor’s HTML
into a hidden field on every keystroke, and reflects the current
selection’s formatting back onto the buttons (aria-pressed). Also hides
and un-names the textarea fallback, naming the hidden field instead.
Without JS: The toolbar and contenteditable region are both hidden (`no-js:hidden`); a plain `<textarea>` with the same `name` takes over (`no-js:block`), so a no-JS user writes and submits plain text instead of HTML.
Show codeHide code
<RichTextEditor name="bio" placeholder="Tell us about yourself" />Add
npx @ava-ui/apogee add rich-text-editor
Pulls in ToggleButton and ToggleButtonGroup. Bring @lucide/astro for
the toolbar icons. Ships one scoped JS island (progressive enhancement).
Usage
---
import RichTextEditor from '@/components/apogee/RichTextEditor/RichTextEditor.astro'
---
<RichTextEditor name="bio" placeholder="Tell us about yourself" />
Anatomy
- Toolbar (
no-js:hidden) — twoToggleButtonGroups ofToggleButtons:- Text style — Bold, Italic, Underline (
execCommandstate commands). - Blocks — Heading, Bulleted list, Numbered list, Link (prompts for a
URL via native
prompt(); onlyhttp(s)andmailtolinks are inserted, anything else is dropped).
- Text style — Bold, Italic, Underline (
- Editor (
no-js:hidden) — thecontenteditableregion (role="textbox",aria-multiline="true"), seeded fromvalue, showsplaceholderwhen empty. - Textarea fallback (
no-js:block, hidden with JS) — a plain<textarea>with the samename, so a no-JS user can still write and submit plain text. - Hidden field —
<input type="hidden">, named and kept in sync with the editor’s HTML on everyinputevent once JS runs (the textarea is un-named then, so only one field submits).
API
@composable — exposed to Content Editors in AVA. Any other attribute
passes through to the root wrapper <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
'content' |
name of the hidden field submitted with the form. |
value |
string |
'' |
Initial HTML, seeded into the editor and the hidden field. |
placeholder |
string |
'Write something…' |
Shown when the editor is empty; the editor’s aria-label when there is no label. |
id |
string |
generated | Id of the editor; the label points at it. |
label |
string |
— | Visible label through Field (aria-labelledby). |
hint |
string |
— | Helper text under the editor. |
error |
string |
— | Error message; marks the editor invalid. |
hasError |
boolean |
false |
Marks the editor invalid without a message. |
disabled |
boolean |
false |
Not editable; the toolbar, the fallback textarea and the hidden value are disabled. |
required |
boolean |
false |
aria-required on the editor and required on the no-JS textarea. |
limit |
number |
— | Character limit: a live “N characters left” line under the editor turns critical past it and the editor becomes invalid. The value still submits; validate it on the server. |
class |
string |
— | Extra classes on the Field root. |
Label, limit, disabled and alignment
Every toolbar button has a tooltip with its shortcut; the alignment group maps to the browser’s justify commands.
20 characters left
Shown on your profile.
Read only.
Show codeHide code
<RichTextEditor name="bio" label="Bio" hint="Shown on your profile." limit={20} />
<RichTextEditor name="locked" label="Locked" value="<p>Read only.</p>" disabled />States
- Toolbar button pressed —
aria-pressedreflects the current selection’s formatting (bold/italic/underline/lists) or block type (heading), updated onselectionchange. - Empty — the placeholder shows via
empty:before:content-[attr(data-placeholder)]. - Focus — the editor is natively focusable (
contenteditable); toolbar buttons showToggleButton’s own focus ring. - No
disabledstate — the component doesn’t expose one.
Examples
Prefilled with initial HTML:
<RichTextEditor name="bio" value="<p>Already <strong>bold</strong>.</p>" />
Accessibility
| Key | Action |
|---|---|
Tab |
Moves between toolbar groups and the editor (one stop per group). |
← → |
Move between buttons in a toolbar group; swapped under dir="rtl". |
Home End |
First / last button in a toolbar group. |
Enter Space |
Toggle the focused toolbar button. |
Esc |
Hides the toolbar tooltip. |
⌘/Ctrl B I U |
In the editor, bold / italic / underline (native). |
⌘/Ctrl K |
In the editor, opens the link prompt (same as the Link button). |
- The editor region is
role="textbox"witharia-multiline="true"and anaria-label(theplaceholder). - Toolbar buttons report their state via
aria-pressed, kept live as the selection changes. - The “Link” button uses the browser’s native
prompt()for the URL — no custom dialog to manage focus for. - No JS: the toolbar and
contenteditableregion are bothno-js:hidden. A plain<textarea>with the samenametakes over (no-js:block), so a no-JS user edits plain text and their edits submit undername— just without rich formatting or the toolbar’s HTML markup.
Tokens
| Token | Used for |
|---|---|
--border-neutral |
Wrapper border + toolbar bottom rule |
--background-page |
Wrapper fill |
--foreground-neutral |
Editor text |
--foreground-primary |
Links inside the editor content |
--foreground-neutral-faded |
Placeholder text |
Do / Don’t
- Do use RichTextEditor for short-form formatted content — bios, comments, descriptions.
- Do treat its output as user-authored HTML — sanitize on the server before storing or rendering it back.
- Don’t expect rich formatting without JS — the no-JS fallback is a
plain
<textarea>(samename), so submissions are plain text, not HTML. - Don’t reach for it for a heavy document-editing surface — bring TipTap/ProseMirror instead.
Content
placeholderis an instruction, not a label (“Tell us about yourself”), sentence case, no ending period.