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.

Bold⌘BItalic⌘IUnderline⌘U
HeadingBulleted listNumbered listLink⌘K
Align leftAlign centerAlign right
Show 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) — two ToggleButtonGroups of ToggleButtons:
    • Text style — Bold, Italic, Underline (execCommand state commands).
    • Blocks — Heading, Bulleted list, Numbered list, Link (prompts for a URL via native prompt(); only http(s) and mailto links are inserted, anything else is dropped).
  • Editor (no-js:hidden) — the contenteditable region (role="textbox", aria-multiline="true"), seeded from value, shows placeholder when empty.
  • Textarea fallback (no-js:block, hidden with JS) — a plain <textarea> with the same name, 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 every input event 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.

Bold⌘BItalic⌘IUnderline⌘U
HeadingBulleted listNumbered listLink⌘K
Align leftAlign centerAlign right

20 characters left

Shown on your profile.

Bold⌘BItalic⌘IUnderline⌘U
HeadingBulleted listNumbered listLink⌘K
Align leftAlign centerAlign right

Read only.

Show 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-pressed reflects the current selection’s formatting (bold/italic/underline/lists) or block type (heading), updated on selectionchange.
  • Empty — the placeholder shows via empty:before:content-[attr(data-placeholder)].
  • Focus — the editor is natively focusable (contenteditable); toolbar buttons show ToggleButton’s own focus ring.
  • No disabled state — 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" with aria-multiline="true" and an aria-label (the placeholder).
  • 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 contenteditable region are both no-js:hidden. A plain <textarea> with the same name takes over (no-js:block), so a no-JS user edits plain text and their edits submit under name — 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> (same name), so submissions are plain text, not HTML.
  • Don’t reach for it for a heavy document-editing surface — bring TipTap/ProseMirror instead.

Content

  • placeholder is an instruction, not a label (“Tell us about yourself”), sentence case, no ending period.