<?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>DEV Community: ThomasNowHere</title>
    <description>The latest articles on DEV Community by ThomasNowHere (@thomasnowheredev).</description>
    <link>https://dev.to/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>DEV Community: ThomasNowHere</title>
      <link>https://dev.to/thomasnowheredev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thomasnowheredev"/>
    <language>en</language>
    <item>
      <title>Notion-Style Block Editor for Any Framework</title>
      <dc:creator>ThomasNowHere</dc:creator>
      <pubDate>Thu, 04 Jun 2026 18:58:34 +0000</pubDate>
      <link>https://dev.to/thomasnowheredev/notion-style-block-editor-for-any-framework-fk6</link>
      <guid>https://dev.to/thomasnowheredev/notion-style-block-editor-for-any-framework-fk6</guid>
      <description>&lt;p&gt;The "Notion experience" is a set of block-level interactions on top of a structured document: hover a block for a drag handle, grab it to reorder with a live drop line, press &lt;code&gt;/&lt;/code&gt; for an insert menu, or "Turn into" to convert a heading into a to-do list. The hard part is shipping all of that headless, so the same block editor works in every framework instead of being locked to one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://domternal.dev" rel="noopener noreferrer"&gt;Domternal&lt;/a&gt; takes a different route: the entire block layer is plain ProseMirror plugins, not framework components. The slash menu, hover handle, drag-to-reorder, "Turn into", block and inline colors, to-do nesting, toggles, and a scroll-spy table of contents are all headless and MIT licensed. They behave identically whether you mount the editor through the React, Angular, Vue, or Vanilla wrapper. And because each one is a separate extension, you load only the pieces you actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "block editing" actually is
&lt;/h2&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%2Fxaprr8tfcflu40yiphhj.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%2Fxaprr8tfcflu40yiphhj.gif" alt="Domternal block context menu open over the editor, showing Delete, Duplicate, Copy link, a Colors palette, and Turn into options" width="500" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notion's magic isn't one feature. It's a handful of small interactions that all agree on the same idea: every paragraph, heading, list, and quote is a &lt;em&gt;block&lt;/em&gt; you can summon, transform, recolor, and move without ever touching the keyboard's arrow keys if you don't want to.&lt;/p&gt;

&lt;p&gt;In Domternal that's a few extensions working together. &lt;code&gt;@domternal/extension-block-menu&lt;/code&gt; carries most of the interaction layer: the slash menu, the hover handle, drag-to-reorder, the block context menu ("Turn into", Duplicate, Copy link), keyboard reordering, and a smart paste that keeps block structure intact instead of flattening it to plain text. &lt;code&gt;@domternal/extension-toc&lt;/code&gt; adds the scroll-spy outline and an inline &lt;code&gt;/toc&lt;/code&gt; block, &lt;code&gt;@domternal/extension-details&lt;/code&gt; adds collapsible toggles, and a few core pieces fill in the rest: &lt;code&gt;BlockColor&lt;/code&gt;, the inline &lt;code&gt;NotionColorPicker&lt;/code&gt;, &lt;code&gt;UniqueID&lt;/code&gt; for stable block ids, and the to-do list nodes.&lt;/p&gt;

&lt;p&gt;They're all just extensions. They register ProseMirror plugins and contribute items through the same &lt;code&gt;addFloatingMenuItems()&lt;/code&gt; hook the core uses. Nothing in them knows or cares which framework renders the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slash menu
&lt;/h2&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%2Fiiwhbrcy6drdm1jvyvcf.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%2Fiiwhbrcy6drdm1jvyvcf.gif" alt="Domternal slash menu: typing /head filters the insert menu to Heading 1, 2, and 3, and selecting one turns the line into a heading" width="664" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Type &lt;code&gt;/&lt;/code&gt; at the start of a line and a filtered popup of insertable blocks appears under the cursor. Keep typing to filter: &lt;code&gt;/head&lt;/code&gt; narrows to the headings, &lt;code&gt;/todo&lt;/code&gt; jumps to the to-do list. Arrow keys move the selection, Enter inserts, and the &lt;code&gt;/query&lt;/code&gt; text you typed is deleted before the block lands so you never have to clean up after it.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SlashCommand&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/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;char&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// the trigger character, '/' by default&lt;/span&gt;
  &lt;span class="na"&gt;invalidNodes&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;codeBlock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// don't hijack '/' inside code&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list of items isn't hard-coded into the slash menu. It's collected from whatever extensions you loaded, so the menu grows automatically as you add features. Each item can declare a &lt;code&gt;hideWhenInside&lt;/code&gt; rule too, which is how "Bullet list" politely disappears from the menu when your cursor is already inside one.&lt;/p&gt;

&lt;p&gt;One detail that's pure Notion: the menu only opens when you actually &lt;em&gt;type&lt;/em&gt; &lt;code&gt;/&lt;/code&gt;. Pasting text that contains a slash, inserting it programmatically, undo/redo, or clicking next to a &lt;code&gt;/&lt;/code&gt; that's already sitting there will not reopen it, so a dismissed slash just becomes plain text. Opening the menu also broadcasts a single "dismiss everything else" signal, so you never end up with two floating menus fighting over the same corner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block handle: hover, grab, add
&lt;/h2&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%2F4wa7ydw9o0228rlraof8.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%2F4wa7ydw9o0228rlraof8.gif" alt="Hovering the left gutter reveals the Domternal block handle; clicking the plus button opens the insert menu" width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hover the left gutter next to any block and a handle fades in. It has two buttons: a six-dot grip and a plus.&lt;/p&gt;

&lt;p&gt;The plus inserts an empty paragraph below and opens the insert menu, so adding a block is one click. The grip does two jobs depending on how you use it: click it to open the block's context menu, or drag it to move the block.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockHandle&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/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// list items and task items get their own handles, Notion-style&lt;/span&gt;
  &lt;span class="na"&gt;hideDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// grace period so the handle doesn't vanish as you reach for it&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;nested: true&lt;/code&gt; is worth a sentence. With it off, only top-level blocks are draggable. With it on, individual list items and task items resolve their own handles, so you can grab one bullet out of a list and drop it somewhere else, exactly like Notion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block context menu: Turn into, Duplicate, Copy link
&lt;/h2&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%2F69l69q0oyam7bf253u4z.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%2F69l69q0oyam7bf253u4z.gif" alt="Opening a paragraph's block context menu and using Turn into to convert it into a heading" width="640" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click the grip instead of dragging it and the block context menu opens. It's the &lt;code&gt;BlockContextMenu&lt;/code&gt; extension, and it carries the actions you reach for most: Delete, Duplicate, Copy link, a Colors submenu, and a "Turn into" section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn into&lt;/strong&gt; converts the current block to another type. The defaults cover the blocks people actually convert between: Paragraph, Heading 1, Heading 2, Heading 3, Bullet list, Ordered list, To-do list, Quote, and Code block.&lt;/p&gt;

&lt;p&gt;The menu is smart about what it offers. It hides the block type you're already in (converting a paragraph into a paragraph is a no-op), it hides list and quote targets when an ancestor is already that type, and it won't offer Quote inside a list item because the schema doesn't allow a blockquote there. You only ever see conversions that will actually work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate&lt;/strong&gt; copies the block with its content, marks, and attributes intact, and regenerates the block's unique id on the copy so deep links never collide. &lt;strong&gt;Delete&lt;/strong&gt; removes it, and if you delete the last block in the document it drops in a fresh empty paragraph so the editor never ends up in an invalid empty state. Small things, but they're the difference between "feels finished" and "feels like a demo".&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockContextMenu&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/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;copyLinkEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// show "Copy link" (needs UniqueID for stable block ids)&lt;/span&gt;
  &lt;span class="c1"&gt;// turnIntoTargets: [...] to customize the "Turn into" list&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;Copy link&lt;/strong&gt; writes a &lt;code&gt;#block-id&lt;/code&gt; URL to your clipboard. It shows up only when &lt;code&gt;UniqueID&lt;/code&gt; is loaded and the block actually has an id, and it pairs with the table of contents, which reads the URL hash on load and scrolls straight to that block. That's the full Notion "copy link to block" round-trip, headless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drag-to-reorder, and the drop line that gets nesting right
&lt;/h2&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%2Fvp551jgq5mby95u37lr6.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%2Fvp551jgq5mby95u37lr6.gif" alt="Dragging a list item by its handle to reorder it, then dragging it to the right to nest it as a child with a dashed drop line" width="800" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part that took the most care. When you drag a block, a line follows the cursor to show where it will land. A flat reorder is easy. The interesting case is lists.&lt;/p&gt;

&lt;p&gt;In Notion, &lt;em&gt;where&lt;/em&gt; you drop horizontally decides the outcome. Drop a block lined up with the list and it becomes a sibling item. Drag it to the right, past the marker, and it becomes a nested child. Domternal mirrors that: the drop indicator is a solid line for a sibling drop and switches to a dashed, indented line once you cross a horizontal threshold into nested-child territory.&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;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nestThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// px from the list item's left edge before a drop nests&lt;/span&gt;
  &lt;span class="na"&gt;autoScroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// scroll the page when you drag near the top or bottom edge&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;nestThreshold&lt;/code&gt; is the x-distance you have to cross before the drop commits to nesting. Set it to &lt;code&gt;0&lt;/code&gt; and every drop stays a sibling. There's also an auto-scroll loop so dragging a block to the far end of a long document scrolls the page for you, with the speed ramping up the closer you get to the edge instead of lurching.&lt;/p&gt;

&lt;p&gt;A drop in the gutter, or in the gap between two blocks, still lands on the nearest block instead of quietly doing nothing. And because the drop indicator and the actual drop are computed by the same function, the line never lies about where the block will end up.&lt;/p&gt;

&lt;p&gt;Prefer the keyboard? &lt;code&gt;KeyboardReorder&lt;/code&gt; moves the current block with &lt;code&gt;Mod-Shift-ArrowUp&lt;/code&gt; and &lt;code&gt;Mod-Shift-ArrowDown&lt;/code&gt;, reusing the exact same move logic as the drag path so the two never disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small detail that makes it feel right: Enter shouldn't open a menu
&lt;/h2&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%2Fvo1ikoqtia27877ide94.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%2Fvo1ikoqtia27877ide94.gif" alt="Pressing Enter leaves a clean empty line with a faint prompt; the insert menu only appears after typing a slash" width="664" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Early on, the insert menu popped up on every empty line. It sounds helpful. It isn't. In Notion, a blank line is just a blank line with a faint "Press '/' for commands" hint, and the menu only shows when you &lt;em&gt;ask&lt;/em&gt; for it.&lt;/p&gt;

&lt;p&gt;So the floating menu has an opt-in for exactly that behavior:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FloatingMenu&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/extension-block-menu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;requireExplicitTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// menu only opens via the + button or by typing '/'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that flag on, pressing Enter gives you a clean empty paragraph and nothing else. The menu is there the instant you click the plus or type a slash, and invisible the rest of the time. It's a tiny change that's the difference between "feels like Notion" and "feels like an editor pretending to be Notion".&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocks that hold other blocks: to-dos and toggles
&lt;/h2&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%2Fozbyte5b9f264h86bqw0.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%2Fozbyte5b9f264h86bqw0.gif" alt="A Domternal to-do list with a checked item and a nested sub-item indented beneath a checkbox" width="800" height="347"&gt;&lt;/a&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%2Fl3lljnrgtms1ds3qgz85.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%2Fl3lljnrgtms1ds3qgz85.gif" alt="Inserting a toggle block and collapsing then expanding it with the triangle, built on native details and summary elements" width="664" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some blocks aren't just one line: they hold other blocks. A list item or a to-do item is a &lt;em&gt;label&lt;/em&gt; line plus a children zone underneath it: press Enter at the end of the label for a sibling, or add blocks below and they nest &lt;em&gt;under&lt;/em&gt; that single bullet or checkbox, exactly like Notion's indented sub-content. The same &lt;code&gt;paragraph block*&lt;/code&gt; model backs bullet lists, ordered lists, and to-do lists, so nesting behaves identically across all three. To-dos get the shortcuts you'd expect: &lt;code&gt;[ ]&lt;/code&gt; and &lt;code&gt;[x]&lt;/code&gt; start an unchecked or checked item, &lt;code&gt;Mod-Enter&lt;/code&gt; ticks the current one, and &lt;code&gt;Mod-Shift-9&lt;/code&gt; toggles a to-do list.&lt;/p&gt;

&lt;p&gt;The toggle is the other one: Notion's little triangle that collapses a block and everything tucked under it. It ships as &lt;code&gt;@domternal/extension-details&lt;/code&gt;, built on the native &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; elements so it's accessible and degrades to plain HTML, and it shows up in the slash menu as "Toggle block".&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Details&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/extension-details&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// remember the open/closed state in the document&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Toggles cooperate with the rest of the block layer too: convert into and out of them from the context menu, drag them around with their contents, and when you follow a table-of-contents link to a heading inside a collapsed toggle, the toggle opens itself so the heading is actually visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Color, inline and per-block
&lt;/h2&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%2Ffz9jhz6qu4ne17zkczwu.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%2Ffz9jhz6qu4ne17zkczwu.gif" alt="Selecting text and applying a yellow background from the Domternal bubble menu's color picker" width="640" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notion has two kinds of color: a whole-block tint and an inline color on a run of text. Domternal ships both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BlockColor&lt;/code&gt; tints an entire block's text or background from a fixed nine-color palette - gray, brown, orange, yellow, green, blue, purple, pink, red - wired into the block context menu's Colors section as text and background swatch rows plus a clear button.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlockColor&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="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setBlockBgColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&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="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;unsetBlockColors&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// clear text + background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For inline color there's &lt;code&gt;NotionColorPicker&lt;/code&gt;, the swatch panel that drops into the selection (bubble) menu with nine text colors and nine backgrounds. The key design choice is that both store &lt;em&gt;named tokens&lt;/em&gt; (&lt;code&gt;data-text-color="red"&lt;/code&gt;), not raw hex. The theme maps each token to a CSS custom property with separate light and dark values, so the same saved document renders correct, readable colors in both themes without you storing theme-specific markup. And because color is "last action wins", tinting a whole block strips any conflicting inline color underneath it, so you never get unreadable text sitting on a colored block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scroll-spy outline
&lt;/h2&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%2Fz51h57k5spnso78dkoc3.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%2Fz51h57k5spnso78dkoc3.gif" alt="The floating scroll-spy table-of-contents outline listing the document's headings with the active one highlighted" width="640" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Long documents need a table of contents, and Notion's is the good kind: a column of ticks pinned to the side that expands into full headings on hover, with the tick for the section you're reading highlighted as you scroll.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@domternal/extension-toc&lt;/code&gt; is three extensions you opt into separately:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// collects headings, owns the active-id state&lt;/span&gt;
  &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the hover-to-expand outline pinned to the side&lt;/span&gt;
  &lt;span class="nx"&gt;TableOfContentsBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// an inline /toc block you can drop in the doc&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/extension-toc&lt;/span&gt;&lt;span class="dl"&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 scroll-spy is the part I'm happiest with. It tracks the active heading with an &lt;code&gt;IntersectionObserver&lt;/code&gt;, falls back to a throttled scroll calculation for the edges, and crucially it can follow a &lt;em&gt;container's&lt;/em&gt; scroll, not just the window. That matters because a real app usually scrolls the editor inside a panel, not the whole page. Pass the scroll container and the outline tracks against it:&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;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;anchor&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="c1"&gt;// stick to the editor container, or 'viewport' for full-page&lt;/span&gt;
  &lt;span class="na"&gt;activeScrollParent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the element that actually scrolls&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking a tick smooth-scrolls to that heading and updates the URL hash, and it ignores scroll updates for a moment afterward so the highlight lands where you clicked instead of flickering on the way there. Heading IDs come from the core &lt;code&gt;UniqueID&lt;/code&gt; extension, so the outline reads stable ids it doesn't have to generate itself.&lt;/p&gt;

&lt;p&gt;Those are two separate surfaces, by the way. &lt;code&gt;FloatingTocOutline&lt;/code&gt; is the side rail; &lt;code&gt;TableOfContentsBlock&lt;/code&gt; is an inline &lt;code&gt;/toc&lt;/code&gt; block you drop into the document body from the slash menu, rendering the same live outline inline and sharing the rail's active-heading state (with a friendly placeholder until the document has headings). Both route through the same &lt;code&gt;scrollToHeading&lt;/code&gt;, and because it writes the URL hash, a link you copied to a block will, on next load, scroll the reader straight to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;A full Notion-style setup is just a list of extensions:&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/extension-block-menu @domternal/extension-toc @domternal/extension-details @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;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="nx"&gt;UniqueID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockColor&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;KeyboardReorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SmartPaste&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/extension-block-menu&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;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TableOfContentsBlock&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/extension-toc&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;Details&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/extension-details&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;querySelector&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;StarterKit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                     &lt;span class="c1"&gt;// paragraphs, headings, lists, to-dos, history&lt;/span&gt;
    &lt;span class="nx"&gt;UniqueID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                       &lt;span class="c1"&gt;// stable block ids for TOC + copy-link&lt;/span&gt;
    &lt;span class="nx"&gt;FloatingMenu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;requireExplicitTrigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nx"&gt;BlockHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nx"&gt;BlockContextMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;SlashCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;KeyboardReorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;SmartPaste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;BlockColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;TableOfContents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;FloatingTocOutline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;TableOfContentsBlock&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;&amp;lt;p&amp;gt;Press '/' for commands&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;That's the headless core. If you're in a framework, you wrap the same editor in &lt;code&gt;@domternal/react&lt;/code&gt;, &lt;code&gt;@domternal/angular&lt;/code&gt;, &lt;code&gt;@domternal/vue&lt;/code&gt;, or &lt;code&gt;@domternal/vanilla&lt;/code&gt;. The block layer doesn't change. The slash menu, the handles, the drop line, the block colors, the outline: all identical, because none of it lives in the framework layer.&lt;/p&gt;

&lt;p&gt;And it stays small. Domternal's own code is about 44 KB gzipped (around 117 KB with ProseMirror itself), and because each feature is a separate extension your bundler strips whatever you don't import. Want the slash menu but not toggles? Leave &lt;code&gt;Details&lt;/code&gt; out and it's gone from the bundle entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a headless, framework-agnostic block editor
&lt;/h2&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%2F1xqatac8w9hvrbx6j4ar.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%2F1xqatac8w9hvrbx6j4ar.png" alt="Angular, React, Vue, and Vanilla JS logos all feeding into the same Domternal block editor" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three reasons, the same three that started the project. &lt;strong&gt;It's composable:&lt;/strong&gt; every piece is its own extension, so you assemble the exact editor you want and your bundler drops the rest. &lt;strong&gt;It's framework-agnostic:&lt;/strong&gt; the block layer is plain ProseMirror, so it behaves the same in React, Angular, Vue, and Vanilla, with no "the drag handle only works in React" footnote. &lt;strong&gt;It's MIT:&lt;/strong&gt; block editing, drag-to-reorder, the outline, the color picker, and toggles are all free, with nothing behind a paid tier.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Notion Mode guide:&lt;/strong&gt; &lt;a href="https://domternal.dev/v1/guides/notion-mode/" rel="noopener noreferrer"&gt;domternal.dev/v1/guides/notion-mode&lt;/a&gt;&lt;/p&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;Getting started:&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;The live editor on the homepage runs the full block setup, slash menu and handles included, so you can grab a paragraph and drag it around right now.&lt;/p&gt;

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

&lt;p&gt;The block layer is broad and tested, but there's always more Notion to chase. The big one is &lt;em&gt;arbitrary&lt;/em&gt; nesting. Today any block can be reordered, and list, to-do, and toggle blocks hold children, but you can't yet nest an arbitrary paragraph under another paragraph the way Notion does. The plan is to ship that as an opt-in package so the default editor keeps emitting clean semantic HTML and stays lightweight, and you only take on nesting's complexity when you actually want it. Columns and a synced-block style reference are the other obvious targets.&lt;/p&gt;

&lt;p&gt;If you put this in front of a real document and something feels even slightly off compared to the editor you're used to, that's exactly the feedback I want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the one block interaction you can't live without?&lt;/strong&gt; Tell me in the comments and I'll see if it's already an extension away.&lt;/p&gt;

</description>
      <category>notion</category>
      <category>showdev</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <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://dev.to/thomasnowheredev/making-domternal-accessible-what-wcag-21-aa-actually-looks-like-in-a-rich-text-editor-4nii</link>
      <guid>https://dev.to/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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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;&amp;nbsp;&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://dev.to/thomasnowheredev/angular-deserves-better-than-react-editor-wrappers-so-i-built-one-2amn</link>
      <guid>https://dev.to/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>
