# RichTextEditor

> A small WYSIWYG editor — a formatting toolbar over a contenteditable region, synced to a hidden field for form submission.

URL: https://apogee.avaui.dev/docs/rich-text-editor
Last updated: 2026-10-15

A small WYSIWYG editor — a formatting toolbar over a contenteditable region, synced to a hidden field for form submission.

Last updated October 15, 2026

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.

```astro
<RichTextEditor name="bio" placeholder="Tell us about yourself" />
```

## Add

```sh
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

```astro
---
import RichTextEditor from '@/components/apogee/RichTextEditor/RichTextEditor.astro'
---
<RichTextEditor name="bio" placeholder="Tell us about yourself" />
```

## Anatomy

-   **Toolbar** (`no-js:hidden`) — two `ToggleButtonGroup`s of `ToggleButton`s:
    -   _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.

```astro
<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:

```astro
<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.

## Related

[

TextareaA labeled multiline input, wired the same way as Input — label, hint, and error.

](https://apogee.avaui.dev/docs/textarea)[

ProseTypography styling for a block of rich, CMS, or markdown HTML you don't control element by element.

](https://apogee.avaui.dev/docs/prose)[

ToggleButtonGroupConnects ToggleButtons into a segmented bar — single-select or multi-select.

](https://apogee.avaui.dev/docs/toggle-button-group)
