DEV Community

Cover image for Making Domternal Accessible. What WCAG 2.1 AA Actually Looks Like in a Rich Text Editor.
ThomasNowHere
ThomasNowHere

Posted on • Originally published at domternal.dev

Making Domternal Accessible. What WCAG 2.1 AA Actually Looks Like in a Rich Text Editor.

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' }),
}),
Enter fullscreen mode Exit fullscreen mode

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.

Chrome DevTools Accessibility tree showing the editor textbox with role, aria-multiline, and aria-label

 

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Keyboard focus shows a visible ring on the Bold button (left), mouse click does not (right)

 

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

The inner ring matches the toolbar background so it doesn't bleed into the swatch color, and the outer ring is the accent color.

Color palette with a circular focus ring on one swatch

 

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.

Focus ring moving between toolbar buttons with ArrowRight

 

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.

ArrowDown opens heading dropdown and cycles through items, Escape closes it

 

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".

Bubble menu with Bold button active on selected bold text

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">
Enter fullscreen mode Exit fullscreen mode

 

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.

Arrow keys navigating the emoji grid, Enter selects an emoji

 

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".

Table with column dropdown showing Insert and Delete options

 

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"

Link popover with URL input

 

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));
Enter fullscreen mode Exit fullscreen mode

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".

Emoji suggestion dropdown showing results for :thu

 

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 of aria-readonly when editable
  • Dynamic aria-readonly: attribute appears when setEditable(false) is called, disappears when set back to true
  • Bubble menu ARIA: role="toolbar", aria-label, aria-pressed on 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-label on search input, role="tablist" on container, role="tab" + aria-selected on category buttons, aria-label on 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-label on URL input, Apply and Remove buttons
  • Image popover: aria-label on URL input, Insert and Browse buttons
  • Table cell toolbar: role="toolbar" with aria-label when visible
  • Emoji suggestion: role="listbox" + aria-label on container, role="option" on items
  • Mention suggestion: role="listbox" + aria-label on container
  • :focus-visible indicators: 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');
});
Enter fullscreen mode Exit fullscreen mode

Playwright accessibility test results showing 56 passed tests

 

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)