Updated September 5, 2026: this article documents the accessibility work shipped in Domternal v0.5.0. Domternal is now framework-agnostic, with first-party Angular, React, Vue 3 and Vanilla integration packages. The historical implementation and its 159 automated checks remain unchanged; current product facts and accessibility limitations have been clarified.
Rich text editors combine a contenteditable surface with toolbars, floating menus, dropdown panels, emoji pickers, table controls, autocomplete suggestions and popovers. Each part needs intentional semantics, keyboard behavior and focus management. In Domternal, that work crosses the editing core, framework integrations and theme rather than living in one component.
I built Domternal, a ProseMirror-based rich text editor toolkit with first-party Angular, React, Vue 3 and Vanilla integration packages. ProseMirror supplied part of the editor's basic keyboard behavior, but it did not automatically make the surrounding UI accessible. Before this pass, several menus, pickers and table controls lacked focus indicators, complete semantics or intentional keyboard paths. The emoji picker did not yet provide arrow-key grid handling.
This article describes the accessibility work included in Domternal v0.5.0. The work crossed 8 packages, and its validation set contained 159 automated E2E checks: 83 Angular and 76 React. Those checks verify DOM attributes, focus, keyboard and CSS behavior; they are not a manual screen-reader audit or a claim of formal WCAG conformance.
1. Editor semantics
ProseMirror renders a contenteditable div. By default, it has no ARIA attributes, so the accessibility tree exposes only a generic editable surface with no product-specific name, multiline state or read-only state.
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' }),
}),
This exposes a named multiline textbox in the accessibility tree. Exact speech and interaction behavior vary by browser, operating system, screen reader and user settings.
The aria-readonly attribute is dynamic. When someone calls editor.setEditable(false), the attribute appears; when the editor becomes editable again, it is removed. This makes the state available to assistive technology without recreating the editor 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 follows browser heuristics and matches when the user agent determines that a focus indicator should be shown. For ordinary toolbar buttons, that typically preserves a visible ring during keyboard navigation without leaving the same ring after a pointer click.
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, such as heading level or font size, the menu pattern takes over. The dropdown container gets role="menu", and 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, its controls lack the group and state semantics assistive technology needs to identify the menu as a formatting toolbar.
I added role="toolbar" and aria-label="Text formatting" on the container. Each toggle button, such as bold, italic and underline, gets aria-pressed synced with the editor state, allowing assistive technology to expose whether the control is active. 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. Moving through that many controls one Tab press at a time is impractical, so the picker needs an intentional two-dimensional keyboard path.
Tab semantics and search
The category selector at the top exposes role="tablist", role="tab" and aria-selected on its category buttons. The search input has aria-label="Search emoji" because the placeholder alone is not a persistent accessible name. These roles and states were part of the v0.5.0 foundation; this pass did not claim the complete WAI-ARIA tabs keyboard interaction.
Grid keyboard navigation
Every emoji swatch has tabindex="-1", removing it from the Tab order. Once focus is placed on a swatch, the v0.5.0 handler moves through the 8-column grid as follows:
- 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. The automated checks exercise this handler after placing focus on a swatch. They do not establish a complete keyboard handoff from the search or category controls into the grid, so this section should not be read as a claim of complete picker keyboard usability.
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 such as "Row options" and "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
The text inputs covered by this pass have an explicit aria-label, so their purpose is available in the accessibility tree instead of relying on placeholder text.
| Input | Label | State exposed to assistive technology |
|---|---|---|
| Link popover URL input | "URL" |
Named URL text input |
| Image popover URL input | "Image URL" |
Named image URL text input |
| Emoji picker search | "Search emoji" |
Named search text input |
| Task item checkbox | "Task status" |
Named checkbox with checked state |
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, making the active option available to assistive technology.
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 the animations and transitions covered by this pass when this preference is set. This includes fade-in animations on floating elements, such as the emoji picker, suggestion dropdowns, toolbar panels and table controls, the gapcursor blink animation, and the theme's covered hover and focus transitions.
@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 and optional with selectionDecoration: false, collapses the ProseMirror selection to a cursor on blur. This keeps the visible selection and toolbar state aligned with editor focus. Assistive-technology behavior varies by browser and screen reader and is not established by these automated checks.
Testing
Accessibility behavior can regress during a refactor. I wrote 159 E2E checks: 83 Angular and 76 React, for the implemented DOM, focus, keyboard and CSS behavior. Each category runs against both framework demo apps via Playwright. These automated checks do not replace manual testing with assistive technologies:
-
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"andtabindex="-1"on items -
Emoji picker ARIA:
aria-labelon search input,role="tablist"on container,role="tab"andaria-selectedon category buttons,aria-labelon each swatch,tabindex="-1"on all swatches - Emoji grid keyboard navigation: ArrowRight/Left/Down/Up movement, boundary behavior without 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"andaria-labelon container,role="option"on items -
Mention suggestion:
role="listbox"andaria-labelon container -
:focus-visibleindicators: keyboard focus shows outline, mouse click does not -
prefers-reduced-motion: animations disabled withanimationDuration: 0s, transitions disabled withtransitionDuration: 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) |
Outside this implementation pass. |
| Image alt text enforcement | Outside this implementation pass. The schema supports the alt attribute; the host application decides whether its authoring policy requires one. |
aria-live regions |
Outside this implementation pass. The editor provides data such as character and word counts, and the consuming app can expose selected updates through role="status" where appropriate. |
| Screen reader testing | Not part of these automated checks. VoiceOver, NVDA and other assistive technologies require a separate manual testing pass. |
The result
Before v0.5.0, keyboard users could type in the content area, but several surrounding toolbars, menus, dropdowns, pickers and table controls lacked the intentional keyboard paths added in this pass.
After v0.5.0:
- The interactive elements covered by this pass have a visible focus indicator on keyboard navigation, not on mouse click
- The toolbar and dropdown paths are covered, and the emoji handler is verified once focus is placed on a swatch
- The covered inputs, buttons and toggles expose accessible names
- The covered dropdowns use the implemented WAI-ARIA menu pattern
- The covered suggestion lists use the implemented listbox pattern
- The covered theme surfaces suppress their documented animations and transitions when reduced motion is requested
- The editor's read-only state is communicated to assistive technology
- 159 E2E checks verify the listed automated behavior across Angular and React; they do not establish screen-reader interoperability or WCAG conformance
Accessibility is not a one-time checkbox. It has to be part of how an editor is built, tested and reviewed.
Domternal Free is a framework-agnostic, MIT-licensed ProseMirror rich text editor with first-party Angular, React, Vue 3 and Vanilla integration packages. The current release includes 17 MIT packages, with 70+ extensions across core and 10 extension packages. For the maintained package inventory, command and test totals, and bundle measurements, see the package guide.
GitHub: github.com/domternal/domternal
Docs: domternal.dev
Live playground: domternal.dev/playground
Accessible authored content: Alt text and ARIA in editor content











Top comments (0)