TutorialAugust 23, 20266 min read

Custom elements that survive a round trip through Word

Put your own element type in a content control, and Word preserves it. Here is the typed API for that, plus the editor UI we built around it.

The application required citations in its documents. A citation appears as (Smith 2024) on the page. It stores sourceId, author, and year, and renders as an editable chip. These properties remain after someone opens and saves the file in Word.

DOCX has no citation type. It does have the structured document tag, w:sdt, which Word calls a content control. Word preserves an SDT and its bound customXml part without interpreting the contained data. You can put a custom element type in a content control, and Word preserves it during a save. @docx-editor.dev/pro defines a convention for the tag and data part. It also provides a typed API, so you do not need to write OOXML.

The application also requires chips, popovers, and a toolbar that matches its other controls. These components require no editor fork because the packaged editor uses the same public engine API.

The code is React. The Vue adapter has the same parts under the same names. The engine is a peer dependency:

npm install @docx-editor.dev/react @docx-editor.dev/core

Custom nodes add @docx-editor.dev/pro, which is licensed for evaluation rather than under Apache 2.0.

The smallest possible editor

import { DocxEditor } from '@docx-editor.dev/react';
import '@docx-editor.dev/core/styles/editor.css';
 
export function Editor({ bytes }: { bytes: Uint8Array }) {
  return (
    <DocxEditor.Root document={bytes}>
      <DocxEditor.Viewport>
        <DocxEditor.Content />
      </DocxEditor.Viewport>
    </DocxEditor.Root>
  );
}

This code creates a working editor without chrome. Root owns the instance and takes the document-level props. Viewport handles scrolling and page layout, and Content renders the pages. Place other components anywhere inside Root.

Using the same parts as the packaged editor

The packaged toolbar uses a public UI API. Its buttons read state and run commands through the same public contract as custom controls. You can therefore use all or part of the packaged toolbar.

Replacing one control keeps every other default in place, including slots added in subsequent package releases:

<DocxEditor.Toolbar>
  <DocxEditor.Toolbar.Bold icon={MyBold} />
  <DocxEditor.Toolbar.Highlight hidden />
</DocxEditor.Toolbar>

icon takes a React element, not a component function. To use custom markup with the packaged behavior, set asChild. It merges the part's handlers, disabled state, active state, and Accessible Rich Internet Applications (ARIA) attributes into the custom element. It does not render a wrapper:

<DocxEditor.Toolbar.Bold asChild>
  <Button variant="ghost">Bold</Button>
</DocxEditor.Toolbar.Bold>

If the packaged order does not meet your requirements, arrange the toolbar parts directly:

<DocxEditor.Toolbar preset={false} className="my-toolbar">
  <DocxEditor.Toolbar.Undo />
  <DocxEditor.Toolbar.Redo />
  <DocxEditor.Toolbar.Separator />
 
  <DocxEditor.Toolbar.StylePicker className="my-picker" />
  <DocxEditor.Toolbar.FontFamily className="my-picker" />
  <DocxEditor.Toolbar.FontSize />
  <DocxEditor.Toolbar.Separator />
 
  <DocxEditor.Toolbar.Bold />
  <DocxEditor.Toolbar.Italic />
  <DocxEditor.Toolbar.FontColor />
 
  <div className="my-toolbar__spacer" />
  <DocxEditor.Toolbar.Zoom />
</DocxEditor.Toolbar>

preset={false} removes the default arrangement and uses the template order. The parts keep their commands and state. The toolbar contains only the listed parts. A later registry addition does not appear until you add it.

For controls without a slot, useEditorCommand returns the same command state that the packaged parts use:

import { useEditorCommand } from '@docx-editor.dev/react';
 
function BoldButton() {
  const bold = useEditorCommand('text.bold');
 
  return (
    <button
      onMouseDown={(e) => e.preventDefault()}
      onClick={() => bold.execute()}
      disabled={!bold.isEnabled}
      data-active={bold.isActive || undefined}
      title={bold.disabledReason ?? 'Bold'}
    >
      B
    </button>
  );
}

The preventDefault() matters because a mousedown can move the caret before the command runs. Skip it on input, select, and textarea, which need focus. isEnabled comes from the engine rather than a custom rule. The toolbar button and context menu therefore use the same state for an action.

useEditorState takes a selector. Pass s.page instead of the complete snapshot. Otherwise, the page indicator renders again when someone toggles bold:

import { useEditorState } from '@docx-editor.dev/react';
 
function PageIndicator() {
  const page = useEditorState((s) => s.page);
  return (
    <span>
      {page.current} / {page.total}
    </span>
  );
}

Defining the element

A custom node defines one element type. The definition specifies its page appearance and stored data.

import { z } from 'zod';
import { customNodesModule, defineCustomNode } from '@docx-editor.dev/pro';
 
export const Citation = defineCustomNode({
  name: 'citation', // second segment of the tag
  tagPrefix: 'acme', // this definition claims acme:*
  schema: z.object({
    sourceId: z.string().min(1),
    author: z.string(),
    year: z.number().int(),
  }),
  text: (data) => `(${data.author} ${String(data.year)})`,
});
 
// Built once, outside render: modules are read when the editor is constructed.
export const MODULES = [customNodesModule({ nodes: [Citation] })];

The schema validates data from .docx files before the application uses it. The editor refuses invalid data before a write. Returned data has the Citation type instead of unknown. Any Standard Schema implementation works.

Pass the modules to the root:

<DocxEditor.Root document={bytes} modules={MODULES}>
  {/* viewport, content, and chrome */}
</DocxEditor.Root>

The content control representation

Each citation is written as an inline w:sdt. Its identity goes in the control's w:tag, and its payload in a customXml part the control binds to. In customXml/item1.xml:

<docxEditor xmlns="urn:docx-editor.dev:custom-node:acme">
  <node id="cx1">
    <label>(Smith 2024)</label>
    <data>{"sourceId":"smith-2024","author":"Smith","year":2024}</data>
  </node>
</docxEditor>

And in word/document.xml, inside the control:

<w:dataBinding
  w:prefixMappings="xmlns:ns0='urn:docx-editor.dev:custom-node:acme'"
  w:xpath="/ns0:docxEditor/ns0:node[@id='cx1']/ns0:label"
  w:storeItemID="{...}"/>

Word renders the control's text and preserves the customXml part. Both remain when the application opens the saved file. This representation uses standard Word features. Word does not interpret the citation data. The application reads that data from the preserved container.

The same structure supports mentions, clause references, and merge tokens. Each type has a display string, a structured payload, and a definition that pairs them.

Inserting a citation

import { insertCustomNode } from '@docx-editor.dev/pro';
import { useDocxEditor } from '@docx-editor.dev/react';
import { Citation } from './citation';
 
function CiteButton() {
  // null before the Root's mount effect creates it, and outside any Root
  const editor = useDocxEditor();
 
  return (
    <button
      disabled={!editor}
      onClick={() => {
        const result = insertCustomNode(editor!, Citation, {
          data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
        });
        if (!result.ok) console.warn(result.reason);
      }}
    >
      Cite
    </button>
  );
}

This inserts (Smith 2024) at the caret. insertCustomNode, updateCustomNode, and removeCustomNode each run in one transaction and create one undo step. Ctrl+Z therefore removes the complete citation. The editor refuses a payload that fails the schema. The refusal includes per-field issues that you can display in the form.

Rendering the citation as a chip

The document still contains ordinary DOCX text. CustomNodeChrome renders custom controls over recognized nodes. The editor locks node content, so edits use the context menu instead of the caret. Both parts mount inside the viewport:

import {
  CustomNodeChrome,
  CustomNodeContextMenu,
} from '@docx-editor.dev/pro/react';
 
<DocxEditor.Viewport>
  <DocxEditor.Content />
  <CustomNodeChrome onNodeClick={(node) => openPopover(node)} />
  <DocxEditor.ContextMenu>
    <CustomNodeContextMenu onEditNode={(node) => openEditForm(node)} />
  </DocxEditor.ContextMenu>
</DocxEditor.Viewport>;

An active node provides name, attrs, tag, data, and a viewport-relative rect for the popover anchor. The callbacks receive nodes from all definitions under one type, so data has the unknown type. dataOf narrows the node to one definition and validates its payload:

<CustomNodeChrome onNodeClick={(node) => open(Citation.dataOf(node))} />

Keep the working document separate from the export

Saving and exporting are separate operations. Store the saved document, not the exported document.

import { saveForExport } from '@docx-editor.dev/pro';
 
await storage.put(docId, new Uint8Array(await editor.save()));
 
const outgoing = await saveForExport(editor);
if (!outgoing.ok) throw new Error(outgoing.reason);
download(outgoing.bytes);

editor.save() creates the stored copy, which reopens with functional chips. saveForExport creates the distributed copy. For each definition, preserveOnExport controls the result. true preserves the complete node, 'text' preserves the text without the control, and false removes both. Store the saved bytes, not the exported bytes. Text from a flattened node does not become a node when the editor reads it again.

customNodesOf(editor) returns recognized nodes in document order. Use this function after Word saves the document and the application reopens the updated file.

Implementation details

Modules are read when the editor is constructed. They do not behave like normal React props. The editor ignores a new array identity after mount. It reads a change only when a new document or fonts value rebuilds the editor. Build the array at module scope to keep its identity stable.

An update returns a new node ID. updateCustomNode replaces the control, so the previous ID stops resolving. Code that stores the previous ID needs the new one.

Do not set z-index on the workspace row or viewport. The z-index creates a stacking context that places the context menu under the custom toolbar.

The navigation pane needs a positioning context. Put it beside the viewport in a row with position: relative. Without this context, normal flow moves the page horizontally.

Next steps

To add a custom element type to a Word document, put it in a content control. Word preserves the control, and your application reads its payload. Your application can then render its required controls without a private file format or editor fork. The custom nodes reference covers every option and every refusal code.