Rich text editors are one of the hardest UI components to make accessible. A contenteditable element, custom toolbars, floating menus, dropdown panels, emoji pickers, table controls, autocomplete suggestions, popovers. Each one needs proper ARIA semantics, keyboard navigation, and focus management. Most of them are built with imperative DOM manipulation, not framework templates.
I built Domternal, a ProseMirror-based rich text editor toolkit with Angular and React wrappers. The core editing was keyboard-accessible from the start, because ProseMirror handles that. But everything around it (toolbars, menus, dropdowns, pickers, table controls) was mouse-only. No focus indicators. No ARIA roles. No keyboard navigation in dropdowns. The emoji picker was completely unreachable with a keyboard.
This is what it took to fix all of it. Changes touched 8 packages, 24 files, backed by 159 E2E tests.
1. Editor semantics
ProseMirror renders a contenteditable div. By default, it has no ARIA attributes. A screen reader user lands on it and hears something like "editable text" with no context: what kind of input is this? Is it a single-line field or a multiline editor? Does it have a label?
I added four attributes to the editor element:
attributes: () => ({
role: 'textbox',
'aria-multiline': 'true',
'aria-label': this.options.ariaLabel ?? 'Rich text editor',
...((this.options.editable ?? true) ? {} : { 'aria-readonly': 'true' }),
}),
Now a screen reader announces: "Rich text editor, editable text". The user immediately knows what they're interacting with.
The aria-readonly attribute is dynamic. When someone calls editor.setEditable(false), the attribute appears and screen readers announce the state change. When set back to true, it's removed. No need to re-read the entire element.
2. Focus indicators: :focus-visible, not :focus
This is a common mistake. Many editors use :focus for styling, which shows focus rings on mouse clicks too. You click a toolbar button and it gets an ugly blue ring. That's not helpful, it's visual noise.
:focus-visible only triggers when the browser detects keyboard navigation, not mouse clicks. This is what you want: a visible ring when someone is Tabbing through the UI, invisible when they're clicking.
I added :focus-visible indicators to 16 interactive element types across 9 SCSS files. The standard pattern:
&:focus-visible {
outline: 2px solid var(--dm-accent, #2563eb);
outline-offset: 1px;
}
This covers toolbar buttons, dropdown items, emoji picker tabs, emoji swatches, suggestion items, table handles, table cell toolbar buttons, table dropdown buttons, table alignment items, image popover buttons, link popover buttons, details toggle buttons, and mention suggestion items.
One exception: color swatches are circular, so a rectangular outline doesn't follow their shape. I used box-shadow instead to create a double ring that matches the swatch border radius:
.dm-color-swatch {
&:focus-visible {
box-shadow: 0 0 0 2px var(--dm-toolbar-bg, #f8f9fa),
0 0 0 3px var(--dm-accent, #2563eb);
}
}
The inner ring matches the toolbar background so it doesn't bleed into the swatch color, and the outer ring is the accent color.
3. Toolbar keyboard navigation
A toolbar without keyboard navigation is just a row of buttons you can Tab through one by one. That's technically keyboard-accessible, but it's a terrible experience when you have 20+ buttons. You'd press Tab 15 times just to reach "Insert Table".
The WAI-ARIA toolbar pattern solves this: one Tab stop for the entire toolbar, then Arrow keys to navigate between buttons.
Roving tabindex
The toolbar uses the roving tabindex pattern. Only the currently focused button has tabindex="0". All others have tabindex="-1". Pressing Tab moves focus out of the toolbar entirely. ArrowLeft/ArrowRight move between buttons. Home and End jump to the first and last button.
Dropdown navigation
When a toolbar button opens a dropdown (like heading level or font size), the menu pattern takes over. The dropdown container gets role="menu", each item gets role="menuitem".
ArrowDown from the trigger opens the dropdown and focuses the first item. ArrowDown/ArrowUp inside the dropdown cycles through items with wrapping, meaning ArrowDown on the last item goes back to the first. Escape closes the dropdown and returns focus to the trigger button.
4. Bubble menu ARIA
The bubble menu is the floating toolbar that appears when you select text. Without ARIA, a screen reader just sees a bunch of unlabeled buttons floating in the DOM.
I added role="toolbar" and aria-label="Text formatting" on the container. Each toggle button (bold, italic, underline) gets aria-pressed synced with the editor state. When the selected text is bold, aria-pressed="true" tells screen readers "Bold, toggle button, pressed". When it's not, "Bold, toggle button, not pressed". Separators between button groups use role="separator".
Both Angular and React implementations keep this in sync:
// React
<button aria-pressed={editor.isActive(item.name)} aria-label={item.label}>
// Angular
<button [attr.aria-pressed]="isItemActive(item)" [attr.aria-label]="item.label">
5. Emoji picker: 2D grid navigation
The emoji picker is a grid of hundreds of small buttons. Without keyboard navigation, it's completely unusable without a mouse. You can't Tab through 500+ emoji one by one.
Tab semantics and search
The category selector at the top uses role="tablist" with role="tab" and aria-selected on each category button. The search input has aria-label="Search emoji" because the placeholder alone is not sufficient: placeholders disappear when you start typing, and some screen readers don't announce them.
Grid keyboard navigation
Every emoji swatch has tabindex="-1", removing it from the Tab order. Instead, arrow keys handle navigation on the grid container. The grid has 8 columns, so:
- ArrowRight/ArrowLeft move horizontally, one emoji at a time
- ArrowDown/ArrowUp jump by 8 to move vertically, one row at a time
- Enter or Space selects the focused emoji
Navigation is bounded, not cyclic. ArrowLeft on the first emoji stays there. ArrowDown on the last row stays on the last row. This is intentional for a 2D grid, where wrapping would be disorienting.
6. Table controls
Tables have the most complex UI in the editor: a cell toolbar with formatting buttons, row/column dropdowns with insert/delete/merge actions, a color palette for cell backgrounds, and an alignment picker. Each one needed the correct ARIA pattern.
The cell toolbar gets role="toolbar" with aria-label="Cell formatting". Row/column dropdowns use role="menu" with contextual labels ("Row options", "Column options"). Every action button inside is role="menuitem". The color palette and alignment picker follow the same pattern. Separators between horizontal and vertical alignment options use role="separator".
7. Input labels
Every text input across the editor has an explicit aria-label. These seem small, but without them, a screen reader user hears "edit text" with no indication of what the input is for.
| Input | Label | What a screen reader says |
|---|---|---|
| Link popover URL input | "URL" |
"URL, edit text" |
| Image popover URL input | "Image URL" |
"Image URL, edit text" |
| Emoji picker search | "Search emoji" |
"Search emoji, edit text" |
| Task item checkbox | "Task status" |
"Task status, checkbox, not checked" |
The floating menu also gets a default role="toolbar" and aria-label="Floating menu" if the user hasn't set one.
8. Autocomplete suggestions
Both the emoji :shortcode: autocomplete and the @mention autocomplete render suggestion dropdowns. These use the listbox pattern:
container.setAttribute('role', 'listbox');
container.setAttribute('aria-label', 'Emoji suggestions');
// Each suggestion item
btn.setAttribute('role', 'option');
btn.setAttribute('aria-selected', String(i === selectedIndex));
aria-selected tracks the currently highlighted item as you navigate with arrow keys, so screen readers announce which option is active: "Thumbs up, option 3 of 5".
9. Reduced motion
Some users have vestibular disorders or motion sensitivity. The prefers-reduced-motion media query lets them opt out of animations and transitions at the OS level.
I disabled all animations and transitions when this preference is set. This covers fade-in animations on floating elements (emoji picker, suggestion dropdowns, toolbar panels, table controls), the gapcursor blink animation, and all hover/focus transition effects on every interactive element.
@media (prefers-reduced-motion: reduce) {
.dm-emoji-picker,
.dm-emoji-suggestion,
.dm-toolbar-dropdown-panel,
.dm-table-controls-dropdown,
.dm-table-cell-toolbar {
animation: none;
}
.dm-toolbar-button,
.dm-emoji-swatch,
.dm-color-swatch,
/* ... and 20+ more selectors */ {
transition: none;
}
}
A CSS cascade lesson I learned the hard way: I initially placed this block in _base.scss, which is imported first in the stylesheet. But the toolbar's transition: background-color 0.15s in _toolbar.scss (imported later) overrode the transition: none. The fix was moving the entire prefers-reduced-motion block to the very end of index.scss, after all other imports, so it wins the cascade.
10. Selection collapse on blur
This is an accessibility and UX fix that's easy to overlook. When you select text in the editor and click outside, the browser's native selection highlight stays visible. This creates "ghost selections": the toolbar shows Bold and Italic as enabled for text that's no longer actively selected. If a user clicks Bold now, it would format text they didn't intend to format.
The SelectionDecoration extension (included in StarterKit, opt-out with selectionDecoration: false) collapses the ProseMirror selection to a cursor on blur. Toolbar buttons correctly show as disabled, no stale formatting can happen, and screen readers don't announce a stale selection range.
Testing
Accessibility without tests is just accessibility until the next refactor. I wrote 159 E2E tests (83 Angular + 76 React) covering every change. Each category runs against both framework demo apps via Playwright:
-
Editor ARIA:
role="textbox",aria-multiline,aria-label,contenteditable, absence ofaria-readonlywhen editable -
Dynamic
aria-readonly: attribute appears whensetEditable(false)is called, disappears when set back totrue -
Bubble menu ARIA:
role="toolbar",aria-label,aria-pressedon toggle buttons (synced with bold/italic state),role="separator" -
Toolbar dropdown keyboard navigation: ArrowDown opens dropdown and focuses first item, ArrowDown/ArrowUp cycle through items, ArrowUp wraps from first to last, Escape closes and returns focus to trigger,
role="menu"on panel,role="menuitem"+tabindex="-1"on items -
Emoji picker ARIA:
aria-labelon search input,role="tablist"on container,role="tab"+aria-selectedon category buttons,aria-labelon each swatch,tabindex="-1"on all swatches - Emoji grid keyboard navigation: ArrowRight/Left/Down/Up movement, boundary behavior (no wrapping), Enter and Space to select, same behavior in search results
-
Task checkbox:
aria-label="Task status"on both checked and unchecked states -
Link popover:
aria-labelon URL input, Apply and Remove buttons -
Image popover:
aria-labelon URL input, Insert and Browse buttons -
Table cell toolbar:
role="toolbar"witharia-labelwhen visible -
Emoji suggestion:
role="listbox"+aria-labelon container,role="option"on items -
Mention suggestion:
role="listbox"+aria-labelon container -
:focus-visibleindicators: keyboard focus shows outline, mouse click does not -
prefers-reduced-motion: animations disabled (animationDuration: 0s), transitions disabled (transitionDuration: 0s)
The prefers-reduced-motion tests use page.emulateMedia({ reducedMotion: 'reduce' }) to simulate the OS preference. The focus-visible tests verify both directions:
test('toolbar button shows outline on keyboard focus', async ({ page }) => {
await page.keyboard.press('Tab');
const btn = page.locator('.dm-toolbar-button').first();
const outline = await btn.evaluate(el => getComputedStyle(el).outlineStyle);
expect(outline).not.toBe('none');
});
test('toolbar button does not show outline on mouse click', async ({ page }) => {
const btn = page.locator('.dm-toolbar-button').first();
await btn.click();
const outline = await btn.evaluate(el => getComputedStyle(el).outlineStyle);
expect(outline).toBe('none');
});
What I skipped (and why)
| Item | Reason |
|---|---|
| Skip navigation link | The editor is an embedded component, not a page. Skip links are for page-level navigation. |
@media (forced-colors) |
Nice to have but not required for WCAG 2.1 AA. On the roadmap. |
| Image alt text enforcement | Content authoring policy, not editor responsibility. The alt attribute is fully supported. |
aria-live regions |
The editor provides data (character count, word count). The consuming app can add role="status" where needed. |
| Screen reader testing | Manual testing with VoiceOver/NVDA. Requires a separate testing pass, not a code change. |
The result
Before v0.5.0, the editor worked for mouse users. Keyboard users could type in the content area, but toolbars, menus, dropdowns, pickers, and table controls were all mouse-only.
After v0.5.0:
- Every interactive element has a visible focus indicator on keyboard navigation (not on mouse click)
- Every toolbar, menu, and picker is fully navigable with arrow keys
- Every input, button, and toggle has an accessible name
- Every dropdown uses the correct WAI-ARIA menu pattern
- Every suggestion list uses the correct listbox pattern
- Motion-sensitive users see no animations or transitions
- The editor's read-only state is communicated to assistive technology
- 159 E2E tests verify all of it across both Angular and React
Accessibility is not a feature you install. It's how the editor works by default.
Domternal is an open-source ProseMirror-based rich text editor with native Angular and React wrappers. 57 extensions, 140+ commands, ~38 KB gzipped, fully tree-shakeable, MIT licensed.
GitHub: github.com/domternal/domternal
Docs: domternal.dev
StackBlitz: Angular | React | Vanilla TS











Top comments (0)