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.

We wanted citations in our documents. A citation reads as (Smith 2024) on the page, carries sourceId, author, and year underneath, renders as an editable chip in our app, and is still all of that when the file comes back from someone who opened it in Word.

DOCX has no citation type. It does have the structured document tag, w:sdt, which Word's own UI calls a content control. Word preserves an SDT and the customXml part it binds to, whether or not Word knows what the thing inside it means. So any element type you want in a Word document goes in a content control, and Word carries it through untouched. What @docx-editor.dev/pro adds is a convention for the tag and the data part, plus a typed API over it, so you are not writing OOXML by hand.

The second half is the UI. Chips, popovers, and a toolbar that matches the rest of our app needed no fork either, because the packaged editor has no privileged access to the engine.

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>
  );
}

That is a working editor with no chrome. Root owns the instance and takes the document-level props, Viewport handles scrolling and page layout, and Content renders the pages. Everything else goes anywhere inside Root.

Using the same parts as the packaged editor

The packaged toolbar is not built on a private UI API. Its buttons read state and run commands through the same public contract ours do, so we could take as much or as little of it as we wanted.

Swapping one control keeps every other default in place, including slots added in later versions:

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

icon takes a React element, not a component function. For our own markup with the packaged behavior intact, asChild merges the part's handlers, disabled state, active state, and ARIA attributes into our element and renders no wrapper:

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

Where the packaged ordering didn't fit, we arranged the bar ourselves:

<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} drops the default arrangement and lets template order take over. The parts keep their commands and their state. The tradeoff: the bar holds exactly what we listed, so a control added to the registry later doesn't appear until we add it.

For controls with no slot at all, useEditorCommand returns what the packaged parts run on:

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: without it the mousedown reaches the document and moves the caret before the command runs. Skip it on input, select, and textarea, which need the focus. isEnabled comes from the engine rather than a rule of our own, so the toolbar button and the context-menu row can't disagree about the same action.

useEditorState takes a selector, so we pass s.page rather than the whole snapshot. Otherwise the page indicator re-renders 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

The toolbar was the easy half. A custom node is the definition of one element type: what it looks like on the page, and what it carries underneath.

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 is not there for our own forms. These payloads arrive from .docx files other people wrote, so it is the boundary between that JSON and the rest of the application: bad data is refused before anything is written, and what we read back is Citation's 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>

Under the hood, it's a content control

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, leaves the customXml part alone, and gives both back on the next open. Nothing here is a private extension, which is the point. We are not asking Word to understand citations. We are asking it to carry a container it has understood for years, and reading our own meaning back out of that container at the other end.

Nothing about this is specific to citations. Mentions, clause references, and merge tokens are the same shape: 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>
  );
}

That drops (Smith 2024) at the caret. insertCustomNode, updateCustomNode, and removeCustomNode each run in one transaction and make one undo step, so Ctrl+Z takes back the whole citation rather than half of it. A payload that fails the schema is refused rather than written, and the refusal carries per-field issues we can put straight back on the form.

Rendering the citation as a chip

The document still holds ordinary DOCX text. CustomNodeChrome draws our own UI over the nodes it recognizes, and nodes are content-locked, so edits go through the context menu rather than 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 activated node carries name, attrs, tag, data, and a viewport-relative rect to anchor the popover to. The callbacks see every definition's nodes under one type, so data arrives as unknown, and dataOf narrows it to one definition and validates the payload on the way through:

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

Keep the working document separate from the export

Saving and exporting are different operations. Conflating them cost us a document early on.

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() is the copy we keep, and it reopens with the chips working. saveForExport is the copy that leaves, where preserveOnExport decides per definition: true sends the node whole, 'text' keeps the words and drops the control, false removes both. We store the saved bytes, never the exported ones. Text from a flattened node does not turn back into a node when it is read again.

Reading them back out is customNodesOf(editor), which returns the recognized nodes in document order. That is how the citation index gets rebuilt when a document comes back from Word.

A few things that weren't obvious

Modules are read when the editor is constructed. We first treated them like normal React props. They aren't: a new array identity after mount is ignored, and the editor only picks up a change when it rebuilds on a new document or fonts. Building the array at module scope makes what we pass the thing that actually runs.

An update returns a new node ID. updateCustomNode replaces the control, so the ID we passed in stops resolving afterward. Anything holding the old ID needs the new one.

Don't set z-index on the workspace row or the viewport. We did, to layer our own chrome bar, and the context menu started rendering underneath it. The z-index creates a stacking context the menu can't escape.

The navigation pane needs a positioning context. Put it beside the viewport in a row with position: relative. Without one it enters normal flow and shoves the page sideways.

That's the whole setup

If you need your own element type inside a Word document, put it in a content control. Word preserves it, other tools leave it alone, and your app reads the payload back out and draws whatever UI it wants over it. No private file format, and no fork of the editor to render it. The custom nodes reference covers every option and every refusal code.