<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: ThomasNowHere</title>
    <description>The latest articles on Forem by ThomasNowHere (@thomasnowheredev).</description>
    <link>https://forem.com/thomasnowheredev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3839649%2F0fbc4ad6-22d9-4a1a-be95-50349cfc6140.png</url>
      <title>Forem: ThomasNowHere</title>
      <link>https://forem.com/thomasnowheredev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/thomasnowheredev"/>
    <language>en</language>
    <item>
      <title>Making Domternal Accessible. What WCAG 2.1 AA Actually Looks Like in a Rich Text Editor.</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Tue, 14 Apr 2026 16:03:25 +0000</pubDate>
      <link>https://forem.com/thomasnowheredev/making-domternal-accessible-what-wcag-21-aa-actually-looks-like-in-a-rich-text-editor-4nii</link>
      <guid>https://forem.com/thomasnowheredev/making-domternal-accessible-what-wcag-21-aa-actually-looks-like-in-a-rich-text-editor-4nii</guid>
      <description>&lt;p&gt;Rich text editors are one of the hardest UI components to make accessible. A &lt;code&gt;contenteditable&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;Domternal&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;This is what it took to fix all of it. Changes touched &lt;strong&gt;8 packages&lt;/strong&gt;, &lt;strong&gt;24 files&lt;/strong&gt;, backed by &lt;strong&gt;159 E2E tests&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  1. Editor semantics
&lt;/h2&gt;

&lt;p&gt;ProseMirror renders a &lt;code&gt;contenteditable&lt;/code&gt; 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?&lt;/p&gt;

&lt;p&gt;I added four attributes to the editor element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-multiline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ariaLabel&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rich text editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...((&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editable&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a screen reader announces: &lt;strong&gt;"Rich text editor, editable text"&lt;/strong&gt;. The user immediately knows what they're interacting with.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;aria-readonly&lt;/code&gt; attribute is dynamic. When someone calls &lt;code&gt;editor.setEditable(false)&lt;/code&gt;, the attribute appears and screen readers announce the state change. When set back to &lt;code&gt;true&lt;/code&gt;, it's removed. No need to re-read the entire element.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgazhfo94mcqdpydt4ttt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgazhfo94mcqdpydt4ttt.png" alt="Chrome DevTools Accessibility tree showing the editor textbox with role, aria-multiline, and aria-label" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  2. Focus indicators: &lt;code&gt;:focus-visible&lt;/code&gt;, not &lt;code&gt;:focus&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is a common mistake. Many editors use &lt;code&gt;:focus&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;:focus-visible&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;:focus-visible&lt;/code&gt; indicators to &lt;strong&gt;16 interactive element types&lt;/strong&gt; across 9 SCSS files. The standard pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-accent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkw3x1dd12px2wxc1v2tv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkw3x1dd12px2wxc1v2tv.png" alt="Keyboard focus shows a visible ring on the Bold button (left), mouse click does not (right)" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;p&gt;One exception: color swatches are circular, so a rectangular &lt;code&gt;outline&lt;/code&gt; doesn't follow their shape. I used &lt;code&gt;box-shadow&lt;/code&gt; instead to create a double ring that matches the swatch border radius:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dm-color-swatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-toolbar-bg&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#f8f9fa&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;dm-accent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inner ring matches the toolbar background so it doesn't bleed into the swatch color, and the outer ring is the accent color.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0bqfwt8efv1zwcwagnq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0bqfwt8efv1zwcwagnq1.png" alt="Color palette with a circular focus ring on one swatch" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  3. Toolbar keyboard navigation
&lt;/h2&gt;

&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;The WAI-ARIA toolbar pattern solves this: one Tab stop for the entire toolbar, then Arrow keys to navigate between buttons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Roving tabindex
&lt;/h3&gt;

&lt;p&gt;The toolbar uses the &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/" rel="noopener noreferrer"&gt;roving tabindex pattern&lt;/a&gt;. Only the currently focused button has &lt;code&gt;tabindex="0"&lt;/code&gt;. All others have &lt;code&gt;tabindex="-1"&lt;/code&gt;. Pressing Tab moves focus out of the toolbar entirely. ArrowLeft/ArrowRight move between buttons. Home and End jump to the first and last button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0eoan8pu2fssbhg82r3a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0eoan8pu2fssbhg82r3a.gif" alt="Focus ring moving between toolbar buttons with ArrowRight" width="1784" height="874"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h3&gt;
  
  
  Dropdown navigation
&lt;/h3&gt;

&lt;p&gt;When a toolbar button opens a dropdown (like heading level or font size), the menu pattern takes over. The dropdown container gets &lt;code&gt;role="menu"&lt;/code&gt;, each item gets &lt;code&gt;role="menuitem"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpjgwbkjucfcn2o21966v.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpjgwbkjucfcn2o21966v.gif" alt="ArrowDown opens heading dropdown and cycles through items, Escape closes it" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  4. Bubble menu ARIA
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;role="toolbar"&lt;/code&gt; and &lt;code&gt;aria-label="Text formatting"&lt;/code&gt; on the container. Each toggle button (bold, italic, underline) gets &lt;code&gt;aria-pressed&lt;/code&gt; synced with the editor state. When the selected text is bold, &lt;code&gt;aria-pressed="true"&lt;/code&gt; tells screen readers &lt;strong&gt;"Bold, toggle button, pressed"&lt;/strong&gt;. When it's not, &lt;strong&gt;"Bold, toggle button, not pressed"&lt;/strong&gt;. Separators between button groups use &lt;code&gt;role="separator"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrbem1awruopa7q2xzlv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrbem1awruopa7q2xzlv.png" alt="Bubble menu with Bold button active on selected bold text" width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both Angular and React implementations keep this in sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;aria-pressed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

// Angular
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt;&lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;aria-pressed&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"isItemActive(item)"&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt;&lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="err"&gt;]&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"item.label"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  5. Emoji picker: 2D grid navigation
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tab semantics and search
&lt;/h3&gt;

&lt;p&gt;The category selector at the top uses &lt;code&gt;role="tablist"&lt;/code&gt; with &lt;code&gt;role="tab"&lt;/code&gt; and &lt;code&gt;aria-selected&lt;/code&gt; on each category button. The search input has &lt;code&gt;aria-label="Search emoji"&lt;/code&gt; because the placeholder alone is not sufficient: placeholders disappear when you start typing, and some screen readers don't announce them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grid keyboard navigation
&lt;/h3&gt;

&lt;p&gt;Every emoji swatch has &lt;code&gt;tabindex="-1"&lt;/code&gt;, removing it from the Tab order. Instead, arrow keys handle navigation on the grid container. The grid has 8 columns, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ArrowRight/ArrowLeft&lt;/strong&gt; move horizontally, one emoji at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArrowDown/ArrowUp&lt;/strong&gt; jump by 8 to move vertically, one row at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enter or Space&lt;/strong&gt; selects the focused emoji&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlnnqw7ryt4ckmv1j1t9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwlnnqw7ryt4ckmv1j1t9.gif" alt="Arrow keys navigating the emoji grid, Enter selects an emoji" width="960" height="546"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  6. Table controls
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The cell toolbar gets &lt;code&gt;role="toolbar"&lt;/code&gt; with &lt;code&gt;aria-label="Cell formatting"&lt;/code&gt;. Row/column dropdowns use &lt;code&gt;role="menu"&lt;/code&gt; with contextual labels ("Row options", "Column options"). Every action button inside is &lt;code&gt;role="menuitem"&lt;/code&gt;. The color palette and alignment picker follow the same pattern. Separators between horizontal and vertical alignment options use &lt;code&gt;role="separator"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6qqd2tkn11zz1unw3en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6qqd2tkn11zz1unw3en.png" alt="Table with column dropdown showing Insert and Delete options" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  7. Input labels
&lt;/h2&gt;

&lt;p&gt;Every text input across the editor has an explicit &lt;code&gt;aria-label&lt;/code&gt;. These seem small, but without them, a screen reader user hears "edit text" with no indication of what the input is for.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;What a screen reader says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Link popover URL input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"URL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"URL, edit text"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image popover URL input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Image URL"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Image URL, edit text"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emoji picker search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Search emoji"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Search emoji, edit text"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task item checkbox&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Task status"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Task status, checkbox, not checked"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5mjwmd5h454n75i79wp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi5mjwmd5h454n75i79wp.png" alt="Link popover with URL input" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;p&gt;The floating menu also gets a default &lt;code&gt;role="toolbar"&lt;/code&gt; and &lt;code&gt;aria-label="Floating menu"&lt;/code&gt; if the user hasn't set one.&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  8. Autocomplete suggestions
&lt;/h2&gt;

&lt;p&gt;Both the emoji &lt;code&gt;:shortcode:&lt;/code&gt; autocomplete and the &lt;code&gt;@mention&lt;/code&gt; autocomplete render suggestion dropdowns. These use the &lt;code&gt;listbox&lt;/code&gt; pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;listbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Emoji suggestions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Each suggestion item&lt;/span&gt;
&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;option&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;selectedIndex&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria-selected&lt;/code&gt; tracks the currently highlighted item as you navigate with arrow keys, so screen readers announce which option is active: &lt;strong&gt;"Thumbs up, option 3 of 5"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifs3ktvzifh6jdeqk0ms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifs3ktvzifh6jdeqk0ms.png" alt="Emoji suggestion dropdown showing results for :thu" width="746" height="772"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  9. Reduced motion
&lt;/h2&gt;

&lt;p&gt;Some users have vestibular disorders or motion sensitivity. The &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query lets them opt out of animations and transitions at the OS level.&lt;/p&gt;

&lt;p&gt;I disabled &lt;strong&gt;all&lt;/strong&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-picker&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-suggestion&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-toolbar-dropdown-panel&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-table-controls-dropdown&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-table-cell-toolbar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-emoji-swatch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.dm-color-swatch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="o"&gt;/*&lt;/span&gt; &lt;span class="nc"&gt;...&lt;/span&gt; &lt;span class="nt"&gt;and&lt;/span&gt; &lt;span class="nt"&gt;20&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nt"&gt;more&lt;/span&gt; &lt;span class="nt"&gt;selectors&lt;/span&gt; &lt;span class="o"&gt;*/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A CSS cascade lesson I learned the hard way&lt;/strong&gt;: I initially placed this block in &lt;code&gt;_base.scss&lt;/code&gt;, which is imported first in the stylesheet. But the toolbar's &lt;code&gt;transition: background-color 0.15s&lt;/code&gt; in &lt;code&gt;_toolbar.scss&lt;/code&gt; (imported later) overrode the &lt;code&gt;transition: none&lt;/code&gt;. The fix was moving the entire &lt;code&gt;prefers-reduced-motion&lt;/code&gt; block to the very end of &lt;code&gt;index.scss&lt;/code&gt;, after all other imports, so it wins the cascade.&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  10. Selection collapse on blur
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SelectionDecoration&lt;/code&gt; extension (included in &lt;code&gt;StarterKit&lt;/code&gt;, opt-out with &lt;code&gt;selectionDecoration: false&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Accessibility without tests is just accessibility until the next refactor. I wrote &lt;strong&gt;159 E2E tests&lt;/strong&gt; (83 Angular + 76 React) covering every change. Each category runs against both framework demo apps via Playwright:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editor ARIA&lt;/strong&gt;: &lt;code&gt;role="textbox"&lt;/code&gt;, &lt;code&gt;aria-multiline&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;contenteditable&lt;/code&gt;, absence of &lt;code&gt;aria-readonly&lt;/code&gt; when editable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic &lt;code&gt;aria-readonly&lt;/code&gt;&lt;/strong&gt;: attribute appears when &lt;code&gt;setEditable(false)&lt;/code&gt; is called, disappears when set back to &lt;code&gt;true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bubble menu ARIA&lt;/strong&gt;: &lt;code&gt;role="toolbar"&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;aria-pressed&lt;/code&gt; on toggle buttons (synced with bold/italic state), &lt;code&gt;role="separator"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toolbar dropdown keyboard navigation&lt;/strong&gt;: 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, &lt;code&gt;role="menu"&lt;/code&gt; on panel, &lt;code&gt;role="menuitem"&lt;/code&gt; + &lt;code&gt;tabindex="-1"&lt;/code&gt; on items&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji picker ARIA&lt;/strong&gt;: &lt;code&gt;aria-label&lt;/code&gt; on search input, &lt;code&gt;role="tablist"&lt;/code&gt; on container, &lt;code&gt;role="tab"&lt;/code&gt; + &lt;code&gt;aria-selected&lt;/code&gt; on category buttons, &lt;code&gt;aria-label&lt;/code&gt; on each swatch, &lt;code&gt;tabindex="-1"&lt;/code&gt; on all swatches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji grid keyboard navigation&lt;/strong&gt;: ArrowRight/Left/Down/Up movement, boundary behavior (no wrapping), Enter and Space to select, same behavior in search results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task checkbox&lt;/strong&gt;: &lt;code&gt;aria-label="Task status"&lt;/code&gt; on both checked and unchecked states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link popover&lt;/strong&gt;: &lt;code&gt;aria-label&lt;/code&gt; on URL input, Apply and Remove buttons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image popover&lt;/strong&gt;: &lt;code&gt;aria-label&lt;/code&gt; on URL input, Insert and Browse buttons&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table cell toolbar&lt;/strong&gt;: &lt;code&gt;role="toolbar"&lt;/code&gt; with &lt;code&gt;aria-label&lt;/code&gt; when visible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji suggestion&lt;/strong&gt;: &lt;code&gt;role="listbox"&lt;/code&gt; + &lt;code&gt;aria-label&lt;/code&gt; on container, &lt;code&gt;role="option"&lt;/code&gt; on items&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mention suggestion&lt;/strong&gt;: &lt;code&gt;role="listbox"&lt;/code&gt; + &lt;code&gt;aria-label&lt;/code&gt; on container&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;:focus-visible&lt;/code&gt; indicators&lt;/strong&gt;: keyboard focus shows outline, mouse click does not&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prefers-reduced-motion&lt;/code&gt;&lt;/strong&gt;: animations disabled (&lt;code&gt;animationDuration: 0s&lt;/code&gt;), transitions disabled (&lt;code&gt;transitionDuration: 0s&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;prefers-reduced-motion&lt;/code&gt; tests use &lt;code&gt;page.emulateMedia({ reducedMotion: 'reduce' })&lt;/code&gt; to simulate the OS preference. The focus-visible tests verify both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolbar button shows outline on keyboard focus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;outlineStyle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toolbar button does not show outline on mouse click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.dm-toolbar-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;outlineStyle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffp620e1ic328mzbaa4cn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffp620e1ic328mzbaa4cn.png" alt="Playwright accessibility test results showing 56 passed tests" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  What I skipped (and why)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skip navigation link&lt;/td&gt;
&lt;td&gt;The editor is an embedded component, not a page. Skip links are for page-level navigation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@media (forced-colors)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nice to have but not required for WCAG 2.1 AA. On the roadmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image alt text enforcement&lt;/td&gt;
&lt;td&gt;Content authoring policy, not editor responsibility. The &lt;code&gt;alt&lt;/code&gt; attribute is fully supported.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;aria-live&lt;/code&gt; regions&lt;/td&gt;
&lt;td&gt;The editor provides data (character count, word count). The consuming app can add &lt;code&gt;role="status"&lt;/code&gt; where needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen reader testing&lt;/td&gt;
&lt;td&gt;Manual testing with VoiceOver/NVDA. Requires a separate testing pass, not a code change.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;After v0.5.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every interactive element has a visible focus indicator on keyboard navigation (not on mouse click)&lt;/li&gt;
&lt;li&gt;Every toolbar, menu, and picker is fully navigable with arrow keys&lt;/li&gt;
&lt;li&gt;Every input, button, and toggle has an accessible name&lt;/li&gt;
&lt;li&gt;Every dropdown uses the correct WAI-ARIA menu pattern&lt;/li&gt;
&lt;li&gt;Every suggestion list uses the correct listbox pattern&lt;/li&gt;
&lt;li&gt;Motion-sensitive users see no animations or transitions&lt;/li&gt;
&lt;li&gt;The editor's read-only state is communicated to assistive technology&lt;/li&gt;
&lt;li&gt;159 E2E tests verify all of it across both Angular and React&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Accessibility is not a feature you install. It's how the editor works by default.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Domternal&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;github.com/domternal/domternal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;domternal.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StackBlitz&lt;/strong&gt;: &lt;a href="https://stackblitz.com/edit/domternal-angular-full-example" rel="noopener noreferrer"&gt;Angular&lt;/a&gt; | &lt;a href="https://stackblitz.com/edit/domternal-react-full-example" rel="noopener noreferrer"&gt;React&lt;/a&gt; | &lt;a href="https://stackblitz.com/edit/domternal-vanilla-full-example" rel="noopener noreferrer"&gt;Vanilla TS&lt;/a&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>react</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Angular Deserves Better Than React Editor Wrappers. So I Built One.</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Mon, 23 Mar 2026 21:10:36 +0000</pubDate>
      <link>https://forem.com/thomasnowheredev/angular-deserves-better-than-react-editor-wrappers-so-i-built-one-2amn</link>
      <guid>https://forem.com/thomasnowheredev/angular-deserves-better-than-react-editor-wrappers-so-i-built-one-2amn</guid>
      <description>&lt;p&gt;If you've ever tried to add a rich text editor to an Angular app, you know how it goes.&lt;/p&gt;

&lt;p&gt;You find a library. It's a wrapper around a React-first editor. You install it, import some module, and it kind of works. Then you need tables. That's a paid feature. You need it to work with &lt;code&gt;OnPush&lt;/code&gt; change detection. It doesn't. You try &lt;code&gt;::ng-deep&lt;/code&gt; to fix the styling. It works until it doesn't. You check the GitHub issues and the Angular wrapper hasn't been updated in months.&lt;/p&gt;

&lt;p&gt;I've been through this cycle on every Angular project I've worked on. After years of dealing with it, I finally built the thing I kept wishing existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Domternal&lt;/strong&gt; is a headless rich text editor with native Angular components. Not a thin wrapper around a React-first editor. A purpose-built editor engine on top of ProseMirror, with native Angular components from the ground up. Signals, OnPush, standalone architecture, and reactive forms out of the box.&lt;/p&gt;

&lt;p&gt;It ships as 10 npm packages under the &lt;code&gt;@domternal&lt;/code&gt; scope. The core is framework-agnostic and fully headless, so it works without Angular too. React and Vue wrappers are planned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the existing options didn't work
&lt;/h2&gt;

&lt;p&gt;I looked at everything out there. Here's what I found:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Community wrappers&lt;/strong&gt; (ngx-tiptap, ngx-quill) are thin bindings around libraries built for other frameworks. They don't use Angular's change detection properly, they require &lt;code&gt;ViewEncapsulation.None&lt;/code&gt; hacks for styling, and features that depend on framework-specific renderers simply don't work in Angular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Existing Angular editors&lt;/strong&gt; (like ngx-editor) are solid ProseMirror-based options for simpler use cases, but they weren't designed for the level of extensibility and Angular integration I needed: Signals-driven reactivity, auto-rendering toolbars, and a large extension ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commercial editors&lt;/strong&gt; (CKEditor, TinyMCE, Kendo UI) either wrap framework-agnostic JavaScript with Angular bindings, or require expensive licenses and buying entire UI suites just to get a text editor. The pricing adds up fast, especially for small teams and startups.&lt;/p&gt;

&lt;p&gt;Meanwhile, React developers have TipTap (35K+ stars), Plate, BlockNote, Lexical, and Remirror, all free, well-maintained, and community-driven. Angular developers have been making do with workarounds for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes Domternal different
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;5 Angular components&lt;/strong&gt;: editor, toolbar, bubble menu, floating menu (in progress), and emoji picker. All built with Signals, OnPush, and standalone components. No NgModules, no &lt;code&gt;::ng-deep&lt;/code&gt;, no fighting the framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables are free.&lt;/strong&gt; Cell merge/split, column resize, cell styling, cell toolbar: 18 table commands total, all MIT licensed. These are features that other editors commonly put behind paid tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The toolbar auto-renders based on your extensions.&lt;/strong&gt; You add an extension, the corresponding toolbar button appears. No manual wiring, no configuration files. It just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;57 extensions across 10 packages&lt;/strong&gt;: headings, lists, code blocks with syntax highlighting, images (paste/drop upload), emoji with picker and suggestions, accordion/details, text color, font size, and more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lightweight and tree-shakeable.&lt;/strong&gt; The core engine is ~38 KB gzipped on its own (47 extensions, toolbar, bubble menu, and floating menu included), ~108 KB with ProseMirror. Additional extensions like tables, images, and emoji are separate packages. Import only what you need and your bundler drops the rest. See the &lt;a href="https://domternal.dev/v1/packages" rel="noopener noreferrer"&gt;full bundle size breakdown&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4,200+ tests&lt;/strong&gt;: 2,675 unit tests and 1,550 E2E tests across 34 Playwright specs. An editor without tests is an editor you can't trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100% TypeScript, zero &lt;code&gt;any&lt;/code&gt;.&lt;/strong&gt; Every type is explicit. Every extension is fully typed. Every command has proper type inference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema conflict detection&lt;/strong&gt;: if you accidentally register two extensions with the same name (common when using StarterKit alongside individual extensions), Domternal throws a clear error instead of silently letting the last one win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick setup
&lt;/h2&gt;

&lt;p&gt;Here's a minimal Angular example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/core @domternal/angular @domternal/theme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;DomternalEditorComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;DomternalToolbarComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/angular&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StarterKit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DomternalEditorComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DomternalToolbarComponent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    @if (editor(); as ed) {
      &amp;lt;domternal-toolbar [editor]="ed" /&amp;gt;
    }
    &amp;lt;domternal-editor
      [extensions]="extensions"
      [content]="content"
      (editorCreated)="editor.set($event)"
    /&amp;gt;
  `&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditorComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Editor&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;StarterKit&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;p&amp;gt;Hello world&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the theme import to your styles and you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@use&lt;/span&gt; &lt;span class="s1"&gt;'@domternal/theme'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;See the &lt;a href="https://stackblitz.com/edit/domternal-angular-full-example" rel="noopener noreferrer"&gt;full Angular example on StackBlitz&lt;/a&gt; with all extensions, toolbar, and bubble menu, or read the &lt;a href="https://domternal.dev/v1/getting-started" rel="noopener noreferrer"&gt;Getting Started guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  No framework? No problem.
&lt;/h3&gt;

&lt;p&gt;The core is fully headless and works without any framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @domternal/core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Italic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Underline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@domternal/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Italic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Underline&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;p&amp;gt;Hello &amp;lt;strong&amp;gt;Bold&amp;lt;/strong&amp;gt;, &amp;lt;em&amp;gt;Italic&amp;lt;/em&amp;gt; and &amp;lt;u&amp;gt;Underline&amp;lt;/u&amp;gt;!&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import only what you need for full control and zero bloat. Use &lt;code&gt;StarterKit&lt;/code&gt; instead for a batteries-included setup with headings, lists, code blocks, history, and more.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;See the &lt;a href="https://stackblitz.com/edit/domternal-vanilla-full-example" rel="noopener noreferrer"&gt;full Vanilla TS example on StackBlitz&lt;/a&gt; with toolbar, bubble menu, and all extensions, or read the &lt;a href="https://domternal.dev/v1/getting-started" rel="noopener noreferrer"&gt;Getting Started guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Domternal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Angular components&lt;/td&gt;
&lt;td&gt;5 (editor, toolbar, bubble menu, floating menu (in progress), emoji picker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensions&lt;/td&gt;
&lt;td&gt;57 across 10 packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nodes&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marks&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commands&lt;/td&gt;
&lt;td&gt;140+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;4,200+ (2,675 unit + 1,550 E2E)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core engine size&lt;/td&gt;
&lt;td&gt;~38 KB gzipped (47 built-in extensions + toolbar + bubble menu + floating menu)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core package size&lt;/td&gt;
&lt;td&gt;~108 KB gzipped (engine + ProseMirror)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tree-shaking&lt;/td&gt;
&lt;td&gt;Import only what you need, unused code is eliminated at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript coverage&lt;/td&gt;
&lt;td&gt;100%, zero &lt;code&gt;any&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table commands&lt;/td&gt;
&lt;td&gt;18 (merge, split, resize, styling, all free)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try it now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;domternal.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/getting-started" rel="noopener noreferrer"&gt;domternal.dev/v1/getting-started&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Packages &amp;amp; Bundle Size:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/packages" rel="noopener noreferrer"&gt;domternal.dev/v1/packages&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/domternal/domternal" rel="noopener noreferrer"&gt;github.com/domternal/domternal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StackBlitz (Angular):&lt;/strong&gt; &lt;a href="https://stackblitz.com/edit/domternal-angular-full-example" rel="noopener noreferrer"&gt;stackblitz.com/edit/domternal-angular-full-example&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StackBlitz (Vanilla TS):&lt;/strong&gt; &lt;a href="https://stackblitz.com/edit/domternal-vanilla-full-example" rel="noopener noreferrer"&gt;stackblitz.com/edit/domternal-vanilla-full-example&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The core is headless and framework-agnostic, so React and Vue wrappers are on the roadmap. Post-MVP extensions like embeds (YouTube/video/audio), math (LaTeX/KaTeX), drag handles, and find &amp;amp; replace are planned based on community demand.&lt;/p&gt;

&lt;p&gt;This is v0.2.0. The editor is stable, tested, and ready to use. I'm still working on polishing the documentation and cleaning up some rough edges. Once that's done, I'll release v1.0.0. In the meantime, I'd genuinely appreciate any feedback on the API design, docs, or anything that could be better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's been your biggest pain point with rich text editing in Angular?&lt;/strong&gt; I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>showdev</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
