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/coreCustom 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.