Skip to content

React

The @domternal/react package provides composable components and hooks that wrap the headless editor with React-native APIs. Works with React 18+ and React 19.

Click to try it out
Terminal window
pnpm add @domternal/core @domternal/theme @domternal/react

Import the theme in your root CSS or entry file:

index.css
@import '@domternal/theme';

The recommended pattern uses the composable Domternal component with namespaced subcomponents:

import { Domternal } from '@domternal/react';
import { StarterKit, BubbleMenu } from '@domternal/core';
export default function Editor() {
return (
<Domternal
extensions={[StarterKit, BubbleMenu]}
content="<p>Hello from React!</p>"
>
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.BubbleMenu
contexts={{ text: ['bold', 'italic', 'underline'] }}
/>
</Domternal>
);
}

Domternal creates the editor and provides it to all subcomponents via React Context. No need to pass editor props manually.

The Domternal component handles editor creation and context internally. Subcomponents access the editor automatically:

<Domternal extensions={extensions} content={content}>
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.BubbleMenu />
<Domternal.EmojiPicker emojis={emojis} />
</Domternal>

Use useEditor when you need direct access to the editor instance or custom rendering:

import { useEditor, EditorProvider, DomternalToolbar } from '@domternal/react';
import { StarterKit } from '@domternal/core';
export default function Editor() {
const { editor, editorRef } = useEditor({
extensions: [StarterKit],
content: '<p>Hello</p>',
});
return (
<EditorProvider editor={editor}>
{editor && <DomternalToolbar />}
<div className="dm-editor">
<div ref={editorRef} />
</div>
</EditorProvider>
);
}

Core hook for creating and managing an editor instance.

const { editor, editorRef } = useEditor(options, deps?);
OptionTypeDefaultDescription
extensionsAnyExtension[][]Extensions to load. Default extensions (Document, Paragraph, Text, BaseKeymap, History) are always included.
contentContent''Initial content (HTML string or JSON). Reactive: changing this prop syncs the editor.
editablebooleantrueWhether the editor is editable. Reactive.
autofocusFocusPositionfalseFocus on mount: true, 'start', 'end', 'all', or a position number.
outputFormat'html' | 'json''html'Format used for content comparison when syncing.
immediatelyRenderbooleantrueSet to false for SSR (Next.js). Editor is created in useEffect instead of during render.
onCreate(editor: Editor) => void-Called when the editor is created.
onUpdate(props: { editor: Editor }) => void-Called when the document content changes.
onSelectionChange(props: { editor: Editor }) => void-Called when the selection changes without content change.
onFocus(props: { editor: Editor; event: FocusEvent }) => void-Called when the editor gains focus.
onBlur(props: { editor: Editor; event: FocusEvent }) => void-Called when the editor loses focus.
onDestroy() => void-Called before the editor is destroyed.
PropertyTypeDescription
editorEditor | nullThe editor instance. Null during SSR when immediatelyRender is false.
editorRefRefObject<HTMLDivElement>Ref to attach to the editor mount point.

Pass a dependency array as the second argument. When any value changes, the editor is destroyed and recreated (content is preserved):

const { editor, editorRef } = useEditor({ extensions, content }, [locale]);
// Editor recreates when locale changes

Subscribe to editor state changes. Two overloads for different use cases.

Returns all state properties. Re-renders when any state changes:

const { htmlContent, jsonContent, isEmpty, isFocused, isEditable } = useEditorState(editor);
PropertyTypeDescription
htmlContentstringCurrent document as HTML.
jsonContentJSONContent | nullCurrent document as JSON.
isEmptybooleanWhether the document is empty.
isFocusedbooleanWhether the editor has focus.
isEditablebooleanWhether the editor is editable.

Returns a derived value. Only re-renders when the selector result changes:

const isBold = useEditorState(editor, (ed) => ed.isActive('bold'));
const wordCount = useEditorState(editor, (ed) => ed.state.doc.textContent.split(/\s+/).filter(Boolean).length);

The selector receives the full Editor instance and returns T | undefined. The return is undefined when the editor is null or destroyed.


Access the editor instance from the nearest EditorProvider or Domternal component:

import { useCurrentEditor } from '@domternal/react';
function MyCustomButton() {
const { editor } = useCurrentEditor();
if (!editor) return null;
return (
<button onClick={() => editor.chain().focus().toggleBold().run()}>
Bold
</button>
);
}

Composable root that creates an editor and provides it via context. Accepts all useEditor options as props.

All useEditor options plus:

PropTypeRequiredDescription
depsDependencyListNoDependency array for forced editor recreation.
childrenReactNodeYesChild components (toolbar, content, menus).
ComponentPropsDescription
Domternal.ContentclassName?: stringRenders the editor content area. Mounts the ProseMirror DOM.
Domternal.Loadingchildren: ReactNodeRenders children only while editor is not ready (SSR loading state).
Domternal.Toolbaricons?: IconSet, layout?: ToolbarLayoutEntry[]Auto-rendering toolbar with optional custom icons and layout.
Domternal.BubbleMenuitems?, contexts?, shouldShow?, placement?, offset?, updateDelay?, children?Context-aware bubble menu. See DomternalBubbleMenu.
Domternal.FloatingMenushouldShow?, offset?, children?Floating menu on empty lines. See DomternalFloatingMenu.
Domternal.EmojiPickeremojis: EmojiPickerItem[]Searchable emoji picker. See DomternalEmojiPicker.

All subcomponents automatically use the editor from context. No editor prop needed.


All-in-one editor with integrated state management, context provider, and controlled mode support. Alternative to the composable pattern.

import { useRef } from 'react';
import { DomternalEditor, DomternalToolbar } from '@domternal/react';
import type { DomternalEditorRef } from '@domternal/react';
export default function Editor() {
const ref = useRef<DomternalEditorRef>(null);
return (
<DomternalEditor
ref={ref}
extensions={[StarterKit]}
content="<p>Hello</p>"
onUpdate={({ editor }) => console.log(editor.getHTML())}
>
<DomternalToolbar />
</DomternalEditor>
);
}

All useEditor options plus:

PropTypeDefaultDescription
classNamestring-CSS class for the .dm-editor wrapper.
outputFormat'html' | 'json''html'Format used by onChange and content comparison.
valueContent-Controlled mode: editor syncs to this value.
onChange(value: string | JSONContent) => void-Controlled mode: called on content changes. Returns HTML string or JSON based on outputFormat.
childrenReactNode-Rendered inside the provider (toolbar, menus).
PropertyTypeDescription
editorEditor | nullThe editor instance.
htmlContentstringCurrent HTML content.
jsonContentJSONContent | nullCurrent JSON content.
isEmptybooleanWhether the document is empty.
isFocusedbooleanWhether the editor has focus.
isEditablebooleanWhether the editor is editable.
const [html, setHtml] = useState('<p>Hello</p>');
<DomternalEditor
extensions={[StarterKit]}
value={html}
onChange={setHtml}
outputFormat="html"
/>

Auto-renders toolbar buttons, dropdowns, separators, and keyboard navigation based on the editor’s extensions.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance. Falls back to context if omitted.
iconsIconSetundefinedCustom icon set. Falls back to built-in defaultIcons.
layoutToolbarLayoutEntry[]undefinedCustom layout to reorder, group, or filter toolbar items.

By default, the toolbar renders all items grouped by their group property. Use layout to customize:

import type { ToolbarLayoutEntry } from '@domternal/core';
const layout: ToolbarLayoutEntry[] = [
'bold', 'italic', 'underline', '|',
'heading', '|',
'bulletList', 'orderedList',
];
<Domternal.Toolbar layout={layout} />
KeyAction
ArrowRightFocus next button
ArrowLeftFocus previous button
HomeFocus first button
EndFocus last button
EscapeClose open dropdown

An inline formatting toolbar that appears when the user selects text.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
itemsstring[]-Fixed list of item names to show. Use `'
contextsRecord<string, string[] | true | null>-Context-aware items. Key is a node type name or 'text'/'table'. Use `'
shouldShow(props: { editor: Editor; view: EditorView; state: EditorState; from: number; to: number }) => boolean-Custom predicate to control visibility.
placement'top' | 'bottom''top'Menu placement relative to the selection.
offsetnumber8Pixel offset from the selection.
updateDelaynumber0Delay in milliseconds before updating position.
childrenReactNode-Additional content rendered after buttons.

Show different buttons depending on what the user selected:

<Domternal.BubbleMenu
contexts={{
text: ['bold', 'italic', 'underline', '|', 'link'],
heading: ['bold', 'italic', '|', 'link'],
codeBlock: null,
image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage'],
}}
/>
  • text - Default for text selections
  • null - Hide the menu in that context
  • true - Show all valid items for that context

Add custom buttons alongside the auto-rendered ones:

<Domternal.BubbleMenu contexts={{ text: ['bold', 'italic'] }}>
<button onClick={handleCustomAction}>Custom</button>
</Domternal.BubbleMenu>

A menu that appears on empty lines, allowing users to insert block-level content.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
shouldShow(props: { editor: Editor; view: EditorView; state: EditorState }) => boolean-Custom predicate to control visibility. Default: shows on empty paragraphs.
offsetnumber0Pixel offset from the cursor position.
childrenReactNode-Content to render inside the menu.
<Domternal.FloatingMenu>
<button onClick={() => editor.commands.setHeading({ level: 1 })}>H1</button>
<button onClick={() => editor.commands.toggleBulletList()}>List</button>
<button onClick={() => editor.commands.setHorizontalRule()}>Divider</button>
</Domternal.FloatingMenu>

A searchable emoji picker panel that opens from the toolbar emoji button.

PropTypeDefaultDescription
editorEditorfrom contextThe editor instance.
emojisEmojiPickerItem[]requiredArray of emoji definitions with emoji, name, and group properties. Import the type from @domternal/react.
import { Emoji, emojis } from '@domternal/extension-emoji';
import type { EmojiPickerItem } from '@domternal/react';
<Domternal extensions={[StarterKit, Emoji]}>
<Domternal.Toolbar />
<Domternal.Content />
<Domternal.EmojiPicker emojis={emojis} />
</Domternal>

The picker opens when the user clicks the emoji toolbar button. It includes search, category tabs, frequently used section, and smooth scrolling.

Low-level component that mounts the ProseMirror DOM into a div. Used with the useEditor hook pattern when you need full control over the layout.

import { useEditor, EditorContent } from '@domternal/react';
import { StarterKit } from '@domternal/core';
export default function Editor() {
const { editor } = useEditor({ extensions: [StarterKit], content: '<p>Hello</p>' });
return <EditorContent editor={editor} className="my-editor" />;
}
PropTypeDefaultDescription
editorEditor | nullrequiredThe editor instance to render.
innerRefRef<HTMLDivElement>-Ref to the underlying DOM container.

Plus all standard HTMLDivElement attributes (className, style, id, etc.).


Render React components inside the editor document. This lets you build interactive, editable nodes with full React lifecycle support.

Converts a React component into a ProseMirror NodeView constructor:

import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, useReactNodeView } from '@domternal/react';
import { Node } from '@domternal/core';
import type { ReactNodeViewProps } from '@domternal/react';
function CalloutComponent({ node, updateAttributes }: ReactNodeViewProps) {
return (
<NodeViewWrapper className={`callout callout-${node.attrs['type']}`}>
<select
value={node.attrs['type'] as string}
onChange={(e) => updateAttributes({ type: e.target.value })}
contentEditable={false}
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<NodeViewContent />
</NodeViewWrapper>
);
}
const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'inline*',
addAttributes() {
return { type: { default: 'info' } };
},
parseHTML() {
return [{ tag: 'div[data-callout]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['div', { ...HTMLAttributes, 'data-callout': node.attrs['type'] }, 0];
},
addNodeView() {
return ReactNodeViewRenderer(CalloutComponent);
},
});
PropTypeDescription
editorEditorThe editor instance.
nodePMNodeThe ProseMirror node being rendered.
selectedbooleanWhether this node is selected via NodeSelection.
getPos() => numberGet the document position of this node.
updateAttributes(attrs) => voidUpdate the node’s attributes.
deleteNode() => voidDelete this node from the document.
extension{ name, options }The extension that created this node view.
decorationsunknown[]ProseMirror decorations applied to this node.
OptionTypeDefaultDescription
asstring'div' / 'span'Wrapper element tag. Defaults to 'div' for block nodes, 'span' for inline.
classNamestring-CSS class on the wrapper element.
contentDOMElementstring | null'div'Tag for the content DOM element. Set to null for nodes with no editable content.

Container component for custom node views. Handles drag events and marks the element as a node view wrapper.

<NodeViewWrapper as="section" className="my-node">
{/* Node view UI */}
</NodeViewWrapper>
PropTypeDefaultDescription
asElementType'div'HTML element type to render.

Plus all standard HTML element attributes.

Placeholder for editable nested content. ProseMirror manages the DOM inside this element.

<NodeViewContent as="p" className="my-content" />
PropTypeDefaultDescription
asElementType'div'HTML element type to render.

Plus all standard HTML element attributes.

Access node view context inside NodeViewWrapper and NodeViewContent:

import { useReactNodeView } from '@domternal/react';
function MyNodeView() {
const { onDragStart, nodeViewContentRef } = useReactNodeView();
// onDragStart: drag event handler
// nodeViewContentRef: ref callback for content DOM
}

Set immediatelyRender to false to prevent the editor from being created during server-side rendering:

<Domternal
extensions={[StarterKit]}
content="<p>Hello</p>"
immediatelyRender={false}
>
<Domternal.Loading>
<div>Loading editor...</div>
</Domternal.Loading>
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>

Domternal.Loading renders its children only while the editor is null (during SSR and before useEffect runs). Once the editor is created, the loading content disappears and the toolbar and content are rendered.

For SSR content generation without a browser, use the core SSR helpers:

import { generateHTML, generateJSON, generateText } from '@domternal/react';
// These are re-exported from @domternal/core for convenience

Apply the theme class to a parent element:

{/* Always dark */}
<div className="dm-theme-dark">
<Domternal extensions={extensions}>
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>
</div>
{/* Follow system preference */}
<div className="dm-theme-auto">
<Domternal extensions={extensions}>
<Domternal.Toolbar />
<Domternal.Content />
</Domternal>
</div>

Toggle at runtime:

function toggleTheme() {
document.body.classList.toggle('dm-theme-dark');
}

Replace the built-in Phosphor icons with your own:

import type { IconSet } from '@domternal/core';
const customIcons: IconSet = {
textB: '<svg>...</svg>',
textItalic: '<svg>...</svg>',
};
<Domternal.Toolbar icons={customIcons} />

When icons is provided, the component uses only that set. To override just a few icons, spread defaultIcons:

import { defaultIcons } from '@domternal/core';
const myIcons = { ...defaultIcons, textB: '<svg><!-- custom bold --></svg>' };

All components can be imported individually or used as Domternal subcomponents:

// Individual imports
import {
useEditor,
useEditorState,
useCurrentEditor,
EditorProvider,
Domternal,
DomternalEditor,
EditorContent,
DomternalToolbar,
DomternalBubbleMenu,
DomternalFloatingMenu,
DomternalEmojiPicker,
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
useReactNodeView,
DEFAULT_EXTENSIONS,
} from '@domternal/react';

Re-exported types from @domternal/core for convenience:

import type { Content, AnyExtension, FocusPosition, JSONContent } from '@domternal/react';
import { Editor } from '@domternal/react';
import { generateHTML, generateJSON, generateText } from '@domternal/react';
  • React 18.0.0 or later (React 19 supported)
  • @domternal/core 0.4.0 or later